ENH - clock app

This commit is contained in:
Eichenholz
2026-08-04 15:20:42 +02:00
parent 723eec5ef9
commit c6bac5b2c6
13 changed files with 944 additions and 54 deletions
+12 -3
View File
@@ -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)
)
}
+8
View File
@@ -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)
})
})
+8
View File
@@ -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)
+13
View File
@@ -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
View File
@@ -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