mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ENH - clock app
This commit is contained in:
@@ -1,6 +1,16 @@
|
||||
export const ALARMS_STORAGE_KEY = 'sky_phone.clock.alarms.v1'
|
||||
|
||||
export const ALARM_SOUND_IDS = ['radar', 'beacon', 'chimes'] as const
|
||||
export const ALARM_SOUND_IDS = [
|
||||
'radar',
|
||||
'beacon',
|
||||
'chimes',
|
||||
'apex',
|
||||
'aurora',
|
||||
'circuit',
|
||||
'constellation',
|
||||
'daybreak',
|
||||
'uplift',
|
||||
] as const
|
||||
export const WEEKDAY_IDS = [1, 2, 3, 4, 5, 6, 0] as const
|
||||
|
||||
export type AlarmSoundId = (typeof ALARM_SOUND_IDS)[number]
|
||||
@@ -40,8 +50,7 @@ const DEFAULT_ALARMS: Alarm[] = [
|
||||
|
||||
function isAlarmSound(value: unknown): value is AlarmSoundId {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
ALARM_SOUND_IDS.includes(value as AlarmSoundId)
|
||||
typeof value === 'string' && ALARM_SOUND_IDS.includes(value as AlarmSoundId)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
formatStopwatch,
|
||||
formatTimer,
|
||||
remainingMilliseconds,
|
||||
timerProgressRatio,
|
||||
timerPickerMilliseconds,
|
||||
timerPickerValue,
|
||||
} from './clock'
|
||||
@@ -23,4 +24,11 @@ describe('timestamp clocks', () => {
|
||||
it('formats stopwatch durations with actual milliseconds', () => {
|
||||
expect(formatStopwatch(61_234)).toBe('01:01.234')
|
||||
})
|
||||
|
||||
it('calculates bounded timer progress from remaining duration', () => {
|
||||
expect(timerProgressRatio(7_500, 10_000)).toBe(0.75)
|
||||
expect(timerProgressRatio(-1, 10_000)).toBe(0)
|
||||
expect(timerProgressRatio(12_000, 10_000)).toBe(1)
|
||||
expect(timerProgressRatio(0, 0)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,6 +33,14 @@ export function formatTimer(milliseconds: number): string {
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function timerProgressRatio(
|
||||
remainingMilliseconds: number,
|
||||
durationMilliseconds: number,
|
||||
): number {
|
||||
if (durationMilliseconds <= 0) return 0
|
||||
return Math.max(0, Math.min(1, remainingMilliseconds / durationMilliseconds))
|
||||
}
|
||||
|
||||
export function timerPickerValue(milliseconds: number): string {
|
||||
const totalSeconds = Math.floor(milliseconds / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ALARM_SOUND_IDS } from './alarms'
|
||||
import { phoneToneDuration } from './tones'
|
||||
|
||||
describe('phone tones', () => {
|
||||
it('defines a playable duration for every alarm sound', () => {
|
||||
for (const sound of ALARM_SOUND_IDS) {
|
||||
expect(phoneToneDuration(sound)).toBeGreaterThan(0)
|
||||
expect(phoneToneDuration(sound)).toBeLessThanOrEqual(1500)
|
||||
}
|
||||
})
|
||||
})
|
||||
+434
-27
@@ -3,13 +3,408 @@ import type { NotificationSoundId } from '@/utils/preferences'
|
||||
|
||||
export type PhoneToneId = AlarmSoundId | NotificationSoundId
|
||||
|
||||
const TONE_PATTERNS: Record<PhoneToneId, number[]> = {
|
||||
beacon: [660, 880, 660, 880],
|
||||
chime: [784, 1047],
|
||||
chimes: [523, 659, 784, 1047],
|
||||
radar: [880, 0, 880, 0, 1175],
|
||||
signal: [740, 988, 740],
|
||||
soft: [523, 659],
|
||||
type ToneVoice = {
|
||||
detune?: number
|
||||
frequency: number
|
||||
gain?: number
|
||||
type?: OscillatorType
|
||||
}
|
||||
|
||||
type ToneStep = {
|
||||
attackMs?: number
|
||||
durationMs: number
|
||||
offsetMs: number
|
||||
releaseMs?: number
|
||||
voices: ToneVoice[]
|
||||
}
|
||||
|
||||
type TonePattern = {
|
||||
loopPauseMs: number
|
||||
steps: ToneStep[]
|
||||
}
|
||||
|
||||
const TONE_PATTERNS: Record<PhoneToneId, TonePattern> = {
|
||||
apex: {
|
||||
loopPauseMs: 420,
|
||||
steps: [
|
||||
{
|
||||
offsetMs: 0,
|
||||
durationMs: 260,
|
||||
voices: [{ frequency: 523 }, { frequency: 1047, gain: 0.16 }],
|
||||
},
|
||||
{
|
||||
offsetMs: 190,
|
||||
durationMs: 280,
|
||||
voices: [{ frequency: 659 }, { frequency: 1319, gain: 0.14 }],
|
||||
},
|
||||
{
|
||||
offsetMs: 390,
|
||||
durationMs: 320,
|
||||
voices: [{ frequency: 784 }, { frequency: 1568, gain: 0.12 }],
|
||||
},
|
||||
{
|
||||
offsetMs: 620,
|
||||
durationMs: 520,
|
||||
releaseMs: 260,
|
||||
voices: [{ frequency: 1047 }, { frequency: 1568, gain: 0.18 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
aurora: {
|
||||
loopPauseMs: 500,
|
||||
steps: [
|
||||
{
|
||||
offsetMs: 0,
|
||||
durationMs: 560,
|
||||
attackMs: 80,
|
||||
releaseMs: 300,
|
||||
voices: [
|
||||
{ frequency: 440, type: 'triangle' },
|
||||
{ frequency: 659, gain: 0.32, type: 'sine' },
|
||||
],
|
||||
},
|
||||
{
|
||||
offsetMs: 310,
|
||||
durationMs: 620,
|
||||
attackMs: 90,
|
||||
releaseMs: 340,
|
||||
voices: [
|
||||
{ frequency: 554, type: 'triangle' },
|
||||
{ frequency: 831, gain: 0.28 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offsetMs: 690,
|
||||
durationMs: 700,
|
||||
attackMs: 100,
|
||||
releaseMs: 400,
|
||||
voices: [
|
||||
{ frequency: 659, type: 'triangle' },
|
||||
{ frequency: 988, gain: 0.24 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
beacon: {
|
||||
loopPauseMs: 460,
|
||||
steps: [
|
||||
{
|
||||
offsetMs: 0,
|
||||
durationMs: 240,
|
||||
voices: [
|
||||
{ frequency: 660, type: 'triangle' },
|
||||
{ frequency: 1320, gain: 0.12 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offsetMs: 250,
|
||||
durationMs: 260,
|
||||
voices: [
|
||||
{ frequency: 880, type: 'triangle' },
|
||||
{ frequency: 1760, gain: 0.1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offsetMs: 540,
|
||||
durationMs: 240,
|
||||
voices: [
|
||||
{ frequency: 660, type: 'triangle' },
|
||||
{ frequency: 1320, gain: 0.12 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offsetMs: 790,
|
||||
durationMs: 340,
|
||||
releaseMs: 160,
|
||||
voices: [
|
||||
{ frequency: 880, type: 'triangle' },
|
||||
{ frequency: 1320, gain: 0.16 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
chime: {
|
||||
loopPauseMs: 360,
|
||||
steps: [
|
||||
{
|
||||
offsetMs: 0,
|
||||
durationMs: 360,
|
||||
releaseMs: 220,
|
||||
voices: [{ frequency: 784 }, { frequency: 1568, gain: 0.16 }],
|
||||
},
|
||||
{
|
||||
offsetMs: 250,
|
||||
durationMs: 500,
|
||||
releaseMs: 320,
|
||||
voices: [{ frequency: 1047 }, { frequency: 2093, gain: 0.12 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
chimes: {
|
||||
loopPauseMs: 480,
|
||||
steps: [
|
||||
{
|
||||
offsetMs: 0,
|
||||
durationMs: 380,
|
||||
releaseMs: 240,
|
||||
voices: [{ frequency: 523 }, { frequency: 1047, gain: 0.15 }],
|
||||
},
|
||||
{
|
||||
offsetMs: 180,
|
||||
durationMs: 420,
|
||||
releaseMs: 260,
|
||||
voices: [{ frequency: 659 }, { frequency: 1319, gain: 0.14 }],
|
||||
},
|
||||
{
|
||||
offsetMs: 370,
|
||||
durationMs: 460,
|
||||
releaseMs: 280,
|
||||
voices: [{ frequency: 784 }, { frequency: 1568, gain: 0.13 }],
|
||||
},
|
||||
{
|
||||
offsetMs: 580,
|
||||
durationMs: 620,
|
||||
releaseMs: 380,
|
||||
voices: [{ frequency: 1047 }, { frequency: 1568, gain: 0.18 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
circuit: {
|
||||
loopPauseMs: 380,
|
||||
steps: [
|
||||
{
|
||||
offsetMs: 0,
|
||||
durationMs: 150,
|
||||
attackMs: 8,
|
||||
releaseMs: 70,
|
||||
voices: [
|
||||
{ frequency: 740, type: 'square', gain: 0.55 },
|
||||
{ frequency: 1480, type: 'sine', gain: 0.1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offsetMs: 170,
|
||||
durationMs: 150,
|
||||
attackMs: 8,
|
||||
releaseMs: 70,
|
||||
voices: [{ frequency: 932, type: 'square', gain: 0.5 }],
|
||||
},
|
||||
{
|
||||
offsetMs: 350,
|
||||
durationMs: 150,
|
||||
attackMs: 8,
|
||||
releaseMs: 70,
|
||||
voices: [{ frequency: 1109, type: 'square', gain: 0.46 }],
|
||||
},
|
||||
{
|
||||
offsetMs: 540,
|
||||
durationMs: 300,
|
||||
attackMs: 10,
|
||||
releaseMs: 170,
|
||||
voices: [
|
||||
{ frequency: 1480, type: 'triangle', gain: 0.58 },
|
||||
{ frequency: 740, type: 'sine', gain: 0.2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
constellation: {
|
||||
loopPauseMs: 520,
|
||||
steps: [
|
||||
{
|
||||
offsetMs: 0,
|
||||
durationMs: 500,
|
||||
releaseMs: 340,
|
||||
voices: [{ frequency: 988 }, { frequency: 1976, detune: 7, gain: 0.1 }],
|
||||
},
|
||||
{
|
||||
offsetMs: 260,
|
||||
durationMs: 540,
|
||||
releaseMs: 360,
|
||||
voices: [
|
||||
{ frequency: 784 },
|
||||
{ frequency: 1568, detune: -6, gain: 0.13 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offsetMs: 540,
|
||||
durationMs: 580,
|
||||
releaseMs: 380,
|
||||
voices: [{ frequency: 1175 }, { frequency: 1760, gain: 0.14 }],
|
||||
},
|
||||
{
|
||||
offsetMs: 820,
|
||||
durationMs: 680,
|
||||
releaseMs: 440,
|
||||
voices: [{ frequency: 1047 }, { frequency: 1568, gain: 0.17 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
daybreak: {
|
||||
loopPauseMs: 500,
|
||||
steps: [
|
||||
{
|
||||
offsetMs: 0,
|
||||
durationMs: 360,
|
||||
voices: [
|
||||
{ frequency: 392, type: 'triangle' },
|
||||
{ frequency: 784, gain: 0.14 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offsetMs: 230,
|
||||
durationMs: 400,
|
||||
voices: [
|
||||
{ frequency: 494, type: 'triangle' },
|
||||
{ frequency: 988, gain: 0.13 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offsetMs: 480,
|
||||
durationMs: 440,
|
||||
voices: [
|
||||
{ frequency: 587, type: 'triangle' },
|
||||
{ frequency: 1175, gain: 0.12 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offsetMs: 750,
|
||||
durationMs: 600,
|
||||
releaseMs: 340,
|
||||
voices: [
|
||||
{ frequency: 784, type: 'triangle' },
|
||||
{ frequency: 1175, gain: 0.2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
radar: {
|
||||
loopPauseMs: 440,
|
||||
steps: [
|
||||
{
|
||||
offsetMs: 0,
|
||||
durationMs: 190,
|
||||
attackMs: 12,
|
||||
releaseMs: 100,
|
||||
voices: [
|
||||
{ frequency: 880, type: 'triangle' },
|
||||
{ frequency: 1760, gain: 0.12 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offsetMs: 360,
|
||||
durationMs: 190,
|
||||
attackMs: 12,
|
||||
releaseMs: 100,
|
||||
voices: [
|
||||
{ frequency: 880, type: 'triangle' },
|
||||
{ frequency: 1760, gain: 0.12 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offsetMs: 720,
|
||||
durationMs: 390,
|
||||
attackMs: 18,
|
||||
releaseMs: 220,
|
||||
voices: [
|
||||
{ frequency: 1175, type: 'triangle' },
|
||||
{ frequency: 1760, gain: 0.15 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
signal: {
|
||||
loopPauseMs: 360,
|
||||
steps: [
|
||||
{
|
||||
offsetMs: 0,
|
||||
durationMs: 180,
|
||||
voices: [{ frequency: 740, type: 'triangle' }],
|
||||
},
|
||||
{
|
||||
offsetMs: 210,
|
||||
durationMs: 220,
|
||||
voices: [{ frequency: 988, type: 'triangle' }],
|
||||
},
|
||||
{
|
||||
offsetMs: 470,
|
||||
durationMs: 300,
|
||||
voices: [
|
||||
{ frequency: 740, type: 'triangle' },
|
||||
{ frequency: 1110, gain: 0.12 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
soft: {
|
||||
loopPauseMs: 420,
|
||||
steps: [
|
||||
{
|
||||
offsetMs: 0,
|
||||
durationMs: 420,
|
||||
attackMs: 55,
|
||||
releaseMs: 260,
|
||||
voices: [
|
||||
{ frequency: 523, gain: 0.72 },
|
||||
{ frequency: 784, gain: 0.16 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offsetMs: 300,
|
||||
durationMs: 520,
|
||||
attackMs: 65,
|
||||
releaseMs: 320,
|
||||
voices: [
|
||||
{ frequency: 659, gain: 0.7 },
|
||||
{ frequency: 988, gain: 0.14 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
uplift: {
|
||||
loopPauseMs: 440,
|
||||
steps: [
|
||||
{
|
||||
offsetMs: 0,
|
||||
durationMs: 260,
|
||||
voices: [
|
||||
{ frequency: 440, type: 'triangle' },
|
||||
{ frequency: 660, gain: 0.18 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offsetMs: 170,
|
||||
durationMs: 280,
|
||||
voices: [
|
||||
{ frequency: 554, type: 'triangle' },
|
||||
{ frequency: 831, gain: 0.17 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offsetMs: 350,
|
||||
durationMs: 300,
|
||||
voices: [
|
||||
{ frequency: 659, type: 'triangle' },
|
||||
{ frequency: 988, gain: 0.16 },
|
||||
],
|
||||
},
|
||||
{
|
||||
offsetMs: 550,
|
||||
durationMs: 520,
|
||||
releaseMs: 300,
|
||||
voices: [
|
||||
{ frequency: 880, type: 'triangle' },
|
||||
{ frequency: 1320, gain: 0.2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export function phoneToneDuration(tone: PhoneToneId): number {
|
||||
return Math.max(
|
||||
...TONE_PATTERNS[tone].steps.map((step) => step.offsetMs + step.durationMs),
|
||||
)
|
||||
}
|
||||
|
||||
export function playPhoneTone(
|
||||
@@ -27,7 +422,8 @@ export function playPhoneTone(
|
||||
}
|
||||
|
||||
const context = new AudioContextConstructor()
|
||||
const frequencies = TONE_PATTERNS[tone]
|
||||
const pattern = TONE_PATTERNS[tone]
|
||||
const patternDuration = phoneToneDuration(tone)
|
||||
const volume = Math.max(0, Math.min(1, volumePercent / 100)) * 0.16
|
||||
let stopped = false
|
||||
let nextPatternTimer: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -36,33 +432,44 @@ export function playPhoneTone(
|
||||
const schedulePattern = (): void => {
|
||||
if (stopped) return
|
||||
const startAt = context.currentTime + 0.02
|
||||
oscillators = frequencies.flatMap((frequency, index) => {
|
||||
if (!frequency) return []
|
||||
const oscillator = context.createOscillator()
|
||||
const gain = context.createGain()
|
||||
const toneStart = startAt + index * 0.24
|
||||
oscillator.type = 'sine'
|
||||
oscillator.frequency.value = frequency
|
||||
gain.gain.setValueAtTime(0, toneStart)
|
||||
gain.gain.linearRampToValueAtTime(volume, toneStart + 0.025)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, toneStart + 0.19)
|
||||
oscillator.connect(gain).connect(context.destination)
|
||||
oscillator.start(toneStart)
|
||||
oscillator.stop(toneStart + 0.2)
|
||||
return [oscillator]
|
||||
})
|
||||
oscillators = pattern.steps.flatMap((step) =>
|
||||
step.voices.map((voice) => {
|
||||
const oscillator = context.createOscillator()
|
||||
const gain = context.createGain()
|
||||
const toneStart = startAt + step.offsetMs / 1000
|
||||
const toneEnd = toneStart + step.durationMs / 1000
|
||||
const attackEnd = toneStart + (step.attackMs ?? 25) / 1000
|
||||
const releaseStart = toneEnd - (step.releaseMs ?? 110) / 1000
|
||||
const peakVolume = Math.max(0.0001, volume * (voice.gain ?? 1))
|
||||
|
||||
oscillator.type = voice.type ?? 'sine'
|
||||
oscillator.frequency.value = voice.frequency
|
||||
oscillator.detune.value = voice.detune ?? 0
|
||||
gain.gain.setValueAtTime(0.0001, toneStart)
|
||||
gain.gain.linearRampToValueAtTime(peakVolume, attackEnd)
|
||||
gain.gain.setValueAtTime(peakVolume, releaseStart)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, toneEnd)
|
||||
oscillator.connect(gain).connect(context.destination)
|
||||
oscillator.start(toneStart)
|
||||
oscillator.stop(toneEnd + 0.01)
|
||||
return oscillator
|
||||
}),
|
||||
)
|
||||
|
||||
if (loop) {
|
||||
nextPatternTimer = setTimeout(
|
||||
schedulePattern,
|
||||
frequencies.length * 240 + 520,
|
||||
patternDuration + pattern.loopPauseMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
void context.resume().then(schedulePattern).catch((error: unknown) => {
|
||||
console.error('[Phone audio] Failed to start tone', error)
|
||||
})
|
||||
void context
|
||||
.resume()
|
||||
.then(schedulePattern)
|
||||
.catch((error: unknown) => {
|
||||
if (!stopped) console.error('[Phone audio] Failed to start tone', error)
|
||||
})
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
|
||||
Reference in New Issue
Block a user