Compare commits

...

3 Commits

Author SHA1 Message Date
Austin Pickett
c3c50000cc feat(desktop): expand completion sounds and add Appearance picker
Add fourteen synthesized turn-end presets with preview in settings, persisted variant selection, and softer default mixing for late-night use.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 00:00:46 -04:00
Brooklyn Nicholson
02e56da0fc refactor(desktop): drop done1 byte sample from completion bank
Keep the curated Web Audio presets only; the embedded sample added bulk without shipping as the default cue.
2026-06-08 19:53:58 -05:00
Brooklyn Nicholson
5e3c5baf82 feat(desktop): add curated completion sound bank for turn completion
Replace the prior haptic-only completion cue with a curated Web Audio completion sound flow, defaulting to the minimal two-note comfort preset while keeping alternate presets available for quick iteration. Play the cue on every message completion event (including background sessions) so turn-end feedback is consistent across active and non-active chats.
2026-06-08 19:50:27 -05:00
9 changed files with 653 additions and 9 deletions

View File

@@ -15,6 +15,7 @@ import {
} from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
import { playCompletionSound } from '@/lib/completion-sound'
import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setClarifyRequest } from '@/store/clarify'
@@ -781,9 +782,7 @@ export function useMessageStream({
flushQueuedDeltas(sessionId)
if (isActiveEvent) {
triggerHaptic('streamDone')
}
playCompletionSound()
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
completeAssistantMessage(sessionId, finalText)

View File

@@ -1,17 +1,21 @@
import { useStore } from '@nanostores/react'
import { LanguageSwitcher } from '@/components/language-switcher'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { useI18n } from '@/i18n'
import { COMPLETION_SOUND_VARIANTS, previewCompletionSound } from '@/lib/completion-sound'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Palette } from '@/lib/icons'
import { Check, Palette, Play } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $completionSoundVariantId, setCompletionSoundVariantId } from '@/store/completion-sound'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { useTheme } from '@/themes/context'
import { BUILTIN_THEMES } from '@/themes/presets'
import { MODE_OPTIONS } from './constants'
import { CONTROL_TEXT, MODE_OPTIONS } from './constants'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
function ThemePreview({ name }: { name: string }) {
@@ -57,6 +61,7 @@ function ThemePreview({ name }: { name: string }) {
export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const completionSoundVariantId = useStore($completionSoundVariantId)
const toolViewMode = useStore($toolViewMode)
const profiles = useStore($profiles)
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
@@ -172,6 +177,52 @@ export function AppearanceSettings() {
description={a.toolViewDesc}
title={a.toolViewTitle}
/>
<ListRow
action={
<div className="flex flex-wrap items-center justify-end gap-2">
<Select
onValueChange={value => {
const variantId = Number.parseInt(value, 10)
setCompletionSoundVariantId(variantId)
previewCompletionSound(variantId)
triggerHaptic('selection')
}}
value={String(completionSoundVariantId)}
>
<SelectTrigger className={cn('min-w-56', CONTROL_TEXT)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMPLETION_SOUND_VARIANTS.map(variant => (
<SelectItem key={variant.id} value={String(variant.id)}>
{variant.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
className="gap-1.5"
onClick={() => {
previewCompletionSound()
triggerHaptic('crisp')
}}
size="sm"
type="button"
variant="outline"
>
<Play className="size-3.5" />
{a.completionSoundPreview}
</Button>
</div>
}
description={a.completionSoundDesc}
title={a.completionSoundTitle}
/>
</div>
</div>
</SettingsContent>

View File

@@ -293,7 +293,10 @@ export const en: Translations = {
technicalDesc: 'Include raw tool args/results and low-level details.',
themeTitle: 'Theme',
themeDesc: 'Desktop palettes only. The selected mode is applied on top.',
themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`
themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`,
completionSoundTitle: 'Completion Sound',
completionSoundDesc: 'Plays when an agent turn finishes. Pick a preset and preview it here.',
completionSoundPreview: 'Preview'
},
fieldLabels: FIELD_LABELS,
fieldDescriptions: FIELD_DESCRIPTIONS,

View File

@@ -216,7 +216,10 @@ export const ja = defineLocale({
technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。',
themeTitle: 'テーマ',
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。',
themeProfileNote: profile => `${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`
themeProfileNote: profile => `${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`,
completionSoundTitle: '完了サウンド',
completionSoundDesc: 'エージェントのターン終了時に再生されます。プリセットを選んでここで試聴できます。',
completionSoundPreview: '試聴'
},
fieldLabels: defineFieldCopy({
model: 'デフォルトモデル',

View File

@@ -220,6 +220,9 @@ export interface Translations {
themeTitle: string
themeDesc: string
themeProfileNote: (profile: string) => string
completionSoundTitle: string
completionSoundDesc: string
completionSoundPreview: string
}
fieldLabels: Record<string, string>
fieldDescriptions: Record<string, string>

View File

@@ -210,7 +210,10 @@ export const zhHant = defineLocale({
technicalDesc: '包含原始工具參數、結果與底層細節。',
themeTitle: '主題',
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。',
themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`
themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`,
completionSoundTitle: '完成提示音',
completionSoundDesc: '代理回合結束時播放。可在此選擇預設並預覽。',
completionSoundPreview: '預覽'
},
fieldLabels: defineFieldCopy({
model: '預設模型',

View File

@@ -288,7 +288,10 @@ export const zh: Translations = {
technicalDesc: '包含原始工具参数/结果及底层细节。',
themeTitle: '主题',
themeDesc: '仅桌面端调色板。所选模式叠加其上。',
themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`
themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`,
completionSoundTitle: '完成提示音',
completionSoundDesc: '智能体回合结束时播放。可在此选择预设并预览。',
completionSoundPreview: '预览'
},
fieldLabels: defineFieldCopy({
model: '默认模型',

View File

@@ -0,0 +1,539 @@
// Completion sound bank for agent turn-end cues.
// Fourteen curated presets for A/B in Settings → Appearance. Default is variant 1.
import { $completionSoundVariantId } from '@/store/completion-sound'
import { $hapticsMuted } from '@/store/haptics'
type OscType = OscillatorType
let ctx: AudioContext | null = null
function getCtx(): AudioContext | null {
if (typeof window === 'undefined') {
return null
}
try {
if (!ctx) {
const Ctor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!Ctor) {
return null
}
ctx = new Ctor()
}
// Autoplay policies can leave the context suspended until a gesture; a
// resume() here recovers it once the user has interacted with the window.
if (ctx.state === 'suspended') {
void ctx.resume().catch(() => undefined)
}
return ctx
} catch {
return null
}
}
// One enveloped oscillator voice → master. Linear attack into an exponential
// decay keeps the tail smooth and avoids the click you get ramping to zero.
function voice(ac: AudioContext, master: GainNode, t0: number, spec: ToneSpec) {
const osc = ac.createOscillator()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const peak = spec.gain ?? 0.5
const attack = spec.attack ?? 0.006
const end = start + spec.dur
osc.type = spec.type ?? 'sine'
osc.frequency.setValueAtTime(spec.freq, start)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0002), start + attack)
env.gain.exponentialRampToValueAtTime(0.0001, end)
osc.connect(env)
env.connect(master)
osc.start(start)
osc.stop(end + 0.02)
}
// Soft pluck: brief triangle strike with an upward glide into the bloom.
function pluckVoice(ac: AudioContext, master: GainNode, t0: number, spec: PluckSpec) {
const osc = ac.createOscillator()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const attack = spec.attack ?? 0.004
const glide = spec.glide ?? 0.16
const end = start + spec.decay
osc.type = 'triangle'
osc.frequency.setValueAtTime(spec.freqFrom, start)
osc.frequency.exponentialRampToValueAtTime(spec.freqTo, start + glide)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + attack)
env.gain.exponentialRampToValueAtTime(0.0001, end)
osc.connect(env)
env.connect(master)
osc.start(start)
osc.stop(end + 0.02)
}
// Slow-swell harmonic bloom — the dreamy tail after the pluck.
function bloomVoice(ac: AudioContext, master: GainNode, t0: number, spec: BloomSpec) {
const osc = ac.createOscillator()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const hold = spec.hold ?? 0.08
const end = start + spec.attack + hold + spec.decay
osc.type = spec.type ?? 'sine'
osc.frequency.setValueAtTime(spec.freq, start)
if (spec.freqTo) {
osc.frequency.exponentialRampToValueAtTime(spec.freqTo, start + spec.attack + hold * 0.6)
}
osc.detune.setValueAtTime(spec.detune ?? 0, start)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + spec.attack)
env.gain.setValueAtTime(Math.max(spec.gain, 0.0002), start + spec.attack + hold)
env.gain.exponentialRampToValueAtTime(0.0001, end)
osc.connect(env)
env.connect(master)
osc.start(start)
osc.stop(end + 0.02)
}
// A whisper of bandpassed noise for PS5-menu airiness.
function airPuff(ac: AudioContext, master: GainNode, t0: number, spec: AirPuffSpec) {
const seconds = 0.12
const length = Math.floor(ac.sampleRate * seconds)
const noise = ac.createBuffer(1, length, ac.sampleRate)
const data = noise.getChannelData(0)
for (let i = 0; i < length; i += 1) {
data[i] = Math.random() * 2 - 1
}
const source = ac.createBufferSource()
const filter = ac.createBiquadFilter()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const end = start + spec.decay
source.buffer = noise
filter.type = 'bandpass'
filter.frequency.setValueAtTime(spec.freq, start)
filter.Q.setValueAtTime(spec.q ?? 1.2, start)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + 0.018)
env.gain.exponentialRampToValueAtTime(0.0001, end)
source.connect(filter)
filter.connect(env)
env.connect(master)
source.start(start)
source.stop(end + 0.02)
}
// Filtered noise sweep — soft send / whoosh gestures.
function whooshVoice(ac: AudioContext, master: GainNode, t0: number, spec: WhooshSpec) {
const seconds = 0.4
const length = Math.floor(ac.sampleRate * seconds)
const noise = ac.createBuffer(1, length, ac.sampleRate)
const data = noise.getChannelData(0)
for (let i = 0; i < length; i += 1) {
data[i] = Math.random() * 2 - 1
}
const source = ac.createBufferSource()
const filter = ac.createBiquadFilter()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const end = start + spec.decay
source.buffer = noise
filter.type = 'bandpass'
filter.frequency.setValueAtTime(spec.freqFrom, start)
filter.frequency.exponentialRampToValueAtTime(spec.freqTo, end)
filter.Q.setValueAtTime(spec.q ?? 0.8, start)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + 0.03)
env.gain.exponentialRampToValueAtTime(0.0001, end)
source.connect(filter)
filter.connect(env)
env.connect(master)
source.start(start)
source.stop(end + 0.02)
}
// Pitch-sweep chirp — modem / sci-fi gestures.
function sweepVoice(ac: AudioContext, master: GainNode, t0: number, spec: SweepSpec) {
const osc = ac.createOscillator()
const env = ac.createGain()
const start = t0 + (spec.start ?? 0)
const attack = spec.attack ?? 0.003
const end = start + spec.decay
osc.type = spec.type ?? 'triangle'
osc.frequency.setValueAtTime(spec.freqFrom, start)
osc.frequency.exponentialRampToValueAtTime(spec.freqTo, end - 0.02)
env.gain.setValueAtTime(0.0001, start)
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + attack)
env.gain.exponentialRampToValueAtTime(0.0001, end)
osc.connect(env)
env.connect(master)
osc.start(start)
osc.stop(end + 0.02)
}
let reverbImpulse: AudioBuffer | null = null
// A short, synthetic reverb tail (decaying noise impulse). Used as a subtle wet
// send so the chimes feel like they sit in a room rather than a tin can — the
// detail that nudges them from "arcade beep" toward "polished app". The impulse
// buffer is generated once and cached; each play gets a fresh, disposable
// convolver so connections never pile up on a shared node.
function makeReverb(ac: AudioContext): ConvolverNode {
if (!reverbImpulse) {
const seconds = 1.6
const length = Math.floor(ac.sampleRate * seconds)
reverbImpulse = ac.createBuffer(2, length, ac.sampleRate)
for (let channel = 0; channel < 2; channel += 1) {
const data = reverbImpulse.getChannelData(channel)
for (let i = 0; i < length; i += 1) {
// White noise with a steep exponential decay → smooth, short tail.
data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2.6
}
}
}
const convolver = ac.createConvolver()
convolver.buffer = reverbImpulse
return convolver
}
export interface CompletionSoundVariant {
id: number
name: string
// `master` is warm (runs through low-pass + room tail).
play: (ac: AudioContext, master: GainNode, t0: number) => void
}
// Note frequencies (equal temperament). Everything lives in a low-mid register
// (C3C5) so the chimes feel warm and "appy" rather than bright and arcade-y.
const A2 = 110
const A3 = 220
const A4 = 440
const A5 = 880
const B5 = 987.77
const C3 = 130.81
const C4 = 261.63
const E4 = 329.63
const E5 = 659.25
const E6 = 1318.51
const G4 = 392
const G5 = 783.99
const C5 = 523.25
const C6 = 1046.5
export const COMPLETION_SOUND_VARIANTS: readonly CompletionSoundVariant[] = [
{
id: 1,
name: 'Two-note comfort',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: E4, dur: 0.22, gain: 0.05, attack: 0.03, type: 'sine' })
voice(ac, master, t0 + 0.08, { freq: C4, dur: 0.52, gain: 0.07, attack: 0.08, type: 'sine' })
voice(ac, master, t0 + 0.08, { freq: C3, dur: 0.46, gain: 0.02, attack: 0.1, type: 'sine' })
}
},
{
id: 2,
name: 'Glass ping',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: C6, dur: 0.55, gain: 0.032, attack: 0.002, type: 'sine' })
voice(ac, master, t0 + 0.01, { freq: E5, dur: 0.42, gain: 0.018, attack: 0.004, type: 'sine' })
airPuff(ac, master, t0, { freq: 3200, gain: 0.004, decay: 0.1, q: 1.4 })
}
},
{
id: 3,
name: 'Soft marimba',
play: (ac, master, t0) => {
pluckVoice(ac, master, t0, { freqFrom: E5, freqTo: G5, gain: 0.03, decay: 0.14, glide: 0.08 })
bloomVoice(ac, master, t0 + 0.04, { freq: C5, gain: 0.028, attack: 0.08, hold: 0.04, decay: 0.62 })
bloomVoice(ac, master, t0 + 0.06, { freq: G4, gain: 0.014, attack: 0.12, hold: 0.06, decay: 0.55 })
}
},
{
id: 4,
name: 'Tri-tone message',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: C6, dur: 0.14, gain: 0.045, attack: 0.004, type: 'sine' })
voice(ac, master, t0 + 0.1, { freq: A5, dur: 0.16, gain: 0.04, attack: 0.004, type: 'sine' })
voice(ac, master, t0 + 0.2, { freq: G5, dur: 0.22, gain: 0.035, attack: 0.006, type: 'sine' })
}
},
{
id: 5,
name: 'Airy whoosh',
play: (ac, master, t0) => {
whooshVoice(ac, master, t0, { freqFrom: 4200, freqTo: 900, gain: 0.022, decay: 0.28, q: 0.7 })
voice(ac, master, t0 + 0.12, { freq: A5, dur: 0.35, gain: 0.02, attack: 0.02, type: 'sine' })
}
},
{
id: 6,
name: 'Discovery cluster',
play: (ac, master, t0) => {
const clusterDetunes = [-14, -5, 0, 7, 12]
clusterDetunes.forEach((detune, i) => {
bloomVoice(ac, master, t0 + i * 0.03, {
freq: A3,
gain: 0.012,
attack: 0.38,
hold: 0.12,
decay: 1.05,
detune
})
})
bloomVoice(ac, master, t0 + 0.1, { freq: E4, gain: 0.008, attack: 0.45, hold: 0.08, decay: 0.9, detune: 3 })
}
},
{
id: 7,
name: 'Systems online',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: C5, dur: 0.16, gain: 0.04, attack: 0.006, type: 'sine' })
voice(ac, master, t0 + 0.09, { freq: G5, dur: 0.28, gain: 0.042, attack: 0.008, type: 'sine' })
voice(ac, master, t0 + 0.09, { freq: C4, dur: 0.24, gain: 0.012, attack: 0.01, type: 'sine' })
}
},
{
id: 8,
name: 'IBM terminal',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: B5, dur: 0.12, gain: 0.038, attack: 0.002, type: 'square' })
voice(ac, master, t0 + 0.14, { freq: E5, dur: 0.1, gain: 0.028, attack: 0.002, type: 'square' })
}
},
{
id: 9,
name: 'Modem chirp',
play: (ac, master, t0) => {
sweepVoice(ac, master, t0, { freqFrom: 320, freqTo: 2200, gain: 0.024, decay: 0.16, type: 'triangle' })
sweepVoice(ac, master, t0 + 0.1, { freqFrom: 480, freqTo: 1400, gain: 0.014, decay: 0.12, type: 'sine' })
}
},
{
id: 10,
name: 'Wind chimes',
play: (ac, master, t0) => {
const chimes = [G5, C6, E5, A5]
chimes.forEach((frequency, i) => {
voice(ac, master, t0 + i * 0.13, {
freq: frequency,
dur: 0.72,
gain: 0.028 - i * 0.003,
attack: 0.003,
type: 'sine'
})
})
}
},
{
id: 11,
name: 'Singing bowl',
play: (ac, master, t0) => {
bloomVoice(ac, master, t0, { freq: A3, gain: 0.022, attack: 0.58, hold: 0.16, decay: 1.35 })
bloomVoice(ac, master, t0 + 0.08, { freq: E4, gain: 0.01, attack: 0.62, hold: 0.12, decay: 1.2, detune: 4 })
bloomVoice(ac, master, t0 + 0.14, { freq: A4, gain: 0.006, attack: 0.68, hold: 0.08, decay: 1.05, detune: -3 })
}
},
{
id: 12,
name: 'Harp lift',
play: (ac, master, t0) => {
const notes = [C5, E5, G5, C6]
notes.forEach((frequency, i) => {
voice(ac, master, t0 + i * 0.075, {
freq: frequency,
dur: 0.38,
gain: 0.034 - i * 0.004,
attack: 0.012,
type: 'sine'
})
})
bloomVoice(ac, master, t0 + 0.2, { freq: C4, gain: 0.01, attack: 0.18, hold: 0.06, decay: 0.7 })
}
},
{
id: 13,
name: 'Sonar ping',
play: (ac, master, t0) => {
voice(ac, master, t0, { freq: A2, dur: 0.95, gain: 0.036, attack: 0.008, type: 'sine' })
voice(ac, master, t0 + 0.42, { freq: A3, dur: 0.55, gain: 0.014, attack: 0.01, type: 'sine' })
airPuff(ac, master, t0, { freq: 600, gain: 0.005, decay: 0.2, q: 0.5 })
}
},
{
id: 14,
name: 'Music box',
play: (ac, master, t0) => {
const notes = [E6, C6, G5, E5]
notes.forEach((frequency, i) => {
pluckVoice(ac, master, t0 + i * 0.09, {
freqFrom: frequency,
freqTo: frequency * 0.998,
gain: 0.02 - i * 0.002,
decay: 0.2,
glide: 0.06
})
})
}
}
] as const
export const DEFAULT_COMPLETION_SOUND_VARIANT_ID = 1
export function resolveCompletionSoundVariantId(variantId: number): number {
if (!Number.isFinite(variantId)) {
return DEFAULT_COMPLETION_SOUND_VARIANT_ID
}
return COMPLETION_SOUND_VARIANTS.some(variant => variant.id === variantId)
? variantId
: DEFAULT_COMPLETION_SOUND_VARIANT_ID
}
function playVariant(variantId: number) {
const variant = COMPLETION_SOUND_VARIANTS.find(v => v.id === variantId)
if (!variant) {
return
}
const ac = getCtx()
if (!ac) {
return
}
// Signal path: voices → master → low-pass → (dry + reverb send) → out.
// Tuned for the dream-menu default: softer level, a touch more air, longer
// wet tail so the bloom feels spacious without a punchy attack.
const master = ac.createGain()
const tone = ac.createBiquadFilter()
tone.type = 'lowpass'
tone.frequency.setValueAtTime(3800, ac.currentTime)
tone.Q.setValueAtTime(0.32, ac.currentTime)
master.gain.setValueAtTime(0.48, ac.currentTime)
master.connect(tone)
const dry = ac.createGain()
dry.gain.setValueAtTime(0.88, ac.currentTime)
tone.connect(dry)
dry.connect(ac.destination)
const reverb = makeReverb(ac)
const wet = ac.createGain()
wet.gain.setValueAtTime(0.34, ac.currentTime)
tone.connect(reverb)
reverb.connect(wet)
wet.connect(ac.destination)
variant.play(ac, master, ac.currentTime + 0.01)
}
// Audition the selected variant from settings. Bypasses the haptics mute toggle so
// sound design can be compared even when turn-end cues are silenced.
export function previewCompletionSound(variantId?: number) {
playVariant(resolveCompletionSoundVariantId(variantId ?? $completionSoundVariantId.get()))
}
// Plays the selected completion cue on any `message.complete`.
export function playCompletionSound() {
if ($hapticsMuted.get()) {
return
}
playVariant($completionSoundVariantId.get())
}
interface AirPuffSpec {
decay: number
freq: number
gain: number
q?: number
start?: number
}
interface BloomSpec {
attack: number
decay: number
detune?: number
freq: number
freqTo?: number
gain: number
hold?: number
start?: number
type?: OscType
}
interface PluckSpec {
attack?: number
decay: number
freqFrom: number
freqTo: number
gain: number
glide?: number
start?: number
}
interface SweepSpec {
attack?: number
decay: number
freqFrom: number
freqTo: number
gain: number
start?: number
type?: OscType
}
interface ToneSpec {
attack?: number
dur: number
freq: number
gain?: number
start?: number
type?: OscType
}
interface WhooshSpec {
decay: number
freqFrom: number
freqTo: number
gain: number
q?: number
start?: number
}

View File

@@ -0,0 +1,40 @@
import { atom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
const COMPLETION_SOUND_VARIANT_STORAGE_KEY = 'hermes.desktop.completionSoundVariantId'
const DEFAULT_COMPLETION_SOUND_VARIANT_ID = 1
const MAX_COMPLETION_SOUND_VARIANT_ID = 14
const MIN_COMPLETION_SOUND_VARIANT_ID = 1
function resolveCompletionSoundVariantId(variantId: number): number {
if (!Number.isFinite(variantId)) {
return DEFAULT_COMPLETION_SOUND_VARIANT_ID
}
if (variantId < MIN_COMPLETION_SOUND_VARIANT_ID || variantId > MAX_COMPLETION_SOUND_VARIANT_ID) {
return DEFAULT_COMPLETION_SOUND_VARIANT_ID
}
return variantId
}
function loadCompletionSoundVariantId(): number {
const stored = storedString(COMPLETION_SOUND_VARIANT_STORAGE_KEY)
if (!stored) {
return DEFAULT_COMPLETION_SOUND_VARIANT_ID
}
return resolveCompletionSoundVariantId(Number.parseInt(stored, 10))
}
export const $completionSoundVariantId = atom(loadCompletionSoundVariantId())
$completionSoundVariantId.subscribe(variantId => {
persistString(COMPLETION_SOUND_VARIANT_STORAGE_KEY, String(resolveCompletionSoundVariantId(variantId)))
})
export function setCompletionSoundVariantId(variantId: number) {
$completionSoundVariantId.set(resolveCompletionSoundVariantId(variantId))
}