@jjlmoya/utils-health 1.31.0 → 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +2 -1
- package/src/entries.ts +3 -1
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/parametricNoiseSynthesizer/bibliography.astro +14 -0
- package/src/tool/parametricNoiseSynthesizer/bibliography.ts +24 -0
- package/src/tool/parametricNoiseSynthesizer/client.ts +342 -0
- package/src/tool/parametricNoiseSynthesizer/component.astro +77 -0
- package/src/tool/parametricNoiseSynthesizer/entry.ts +27 -0
- package/src/tool/parametricNoiseSynthesizer/i18n/de.ts +295 -0
- package/src/tool/parametricNoiseSynthesizer/i18n/en.ts +295 -0
- package/src/tool/parametricNoiseSynthesizer/i18n/es.ts +295 -0
- package/src/tool/parametricNoiseSynthesizer/i18n/fr.ts +295 -0
- package/src/tool/parametricNoiseSynthesizer/i18n/id.ts +295 -0
- package/src/tool/parametricNoiseSynthesizer/i18n/it.ts +295 -0
- package/src/tool/parametricNoiseSynthesizer/i18n/ja.ts +295 -0
- package/src/tool/parametricNoiseSynthesizer/i18n/ko.ts +295 -0
- package/src/tool/parametricNoiseSynthesizer/i18n/nl.ts +295 -0
- package/src/tool/parametricNoiseSynthesizer/i18n/pl.ts +295 -0
- package/src/tool/parametricNoiseSynthesizer/i18n/pt.ts +295 -0
- package/src/tool/parametricNoiseSynthesizer/i18n/ru.ts +295 -0
- package/src/tool/parametricNoiseSynthesizer/i18n/sv.ts +295 -0
- package/src/tool/parametricNoiseSynthesizer/i18n/tr.ts +295 -0
- package/src/tool/parametricNoiseSynthesizer/i18n/zh.ts +295 -0
- package/src/tool/parametricNoiseSynthesizer/index.ts +9 -0
- package/src/tool/parametricNoiseSynthesizer/logic.ts +12 -0
- package/src/tool/parametricNoiseSynthesizer/parametric-pink-brown-noise-synthesizer.css +300 -0
- package/src/tool/parametricNoiseSynthesizer/seo.astro +15 -0
- package/src/tool/parametricNoiseSynthesizer/ui.ts +21 -0
- package/src/tools.ts +2 -1
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { waistHipRatioCalculator } from '../tool/waistHipRatioCalculator/entry';
|
|
|
7
7
|
import { waistHeightRatioCalculator } from '../tool/waistHeightRatioCalculator/entry';
|
|
8
8
|
import { daltonismSimulator } from '../tool/daltonismSimulator/entry';
|
|
9
9
|
import { tinnitusReliever } from '../tool/tinnitusReliever/entry';
|
|
10
|
+
import { parametricNoiseSynthesizer } from '../tool/parametricNoiseSynthesizer/entry';
|
|
10
11
|
import { breathingVisualizer } from '../tool/breathingVisualizer/entry';
|
|
11
12
|
import { caffeineTracker } from '../tool/caffeineTracker/entry';
|
|
12
13
|
import { caloricDeficitCalculator } from '../tool/caloricDeficitCalculator/entry';
|
|
@@ -30,7 +31,7 @@ import { correctedReticulocyteIndex } from '../tool/correctedReticulocyteIndex/e
|
|
|
30
31
|
|
|
31
32
|
export const healthCategory: HealthCategoryEntry = {
|
|
32
33
|
icon: 'mdi:heart-pulse',
|
|
33
|
-
tools: [bmiCalculator, amputationAdjustedBmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, leanBodyMassCalculator, metabolicAgeCalculator, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, serumOsmolalityCalculator, correctedSodiumHyperglycemiaCalculator, fractionalExcretionSodiumCalculator, correctedReticulocyteIndex, targetHeightEstimator, daltonismSimulator, tinnitusReliever, breathingVisualizer, caffeineTracker, waterPurifier, digestionStopwatch, binauralTuner, pelliRobsonTest, peripheralVisionTrainer, epworthSleepinessScale],
|
|
34
|
+
tools: [bmiCalculator, amputationAdjustedBmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, leanBodyMassCalculator, metabolicAgeCalculator, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, serumOsmolalityCalculator, correctedSodiumHyperglycemiaCalculator, fractionalExcretionSodiumCalculator, correctedReticulocyteIndex, targetHeightEstimator, daltonismSimulator, tinnitusReliever, parametricNoiseSynthesizer, breathingVisualizer, caffeineTracker, waterPurifier, digestionStopwatch, binauralTuner, pelliRobsonTest, peripheralVisionTrainer, epworthSleepinessScale],
|
|
34
35
|
i18n: {
|
|
35
36
|
en: () => import('./i18n/en').then((m) => m.content),
|
|
36
37
|
es: () => import('./i18n/es').then((m) => m.content),
|
package/src/entries.ts
CHANGED
|
@@ -29,6 +29,7 @@ export { peripheralVisionTrainer } from './tool/peripheralVisionTrainer/entry';
|
|
|
29
29
|
export { readingDistanceCalculator } from './tool/readingDistanceCalculator/entry';
|
|
30
30
|
export { screenDecompressionTime } from './tool/screenDecompressionTime/entry';
|
|
31
31
|
export { tinnitusReliever } from './tool/tinnitusReliever/entry';
|
|
32
|
+
export { parametricNoiseSynthesizer } from './tool/parametricNoiseSynthesizer/entry';
|
|
32
33
|
export { ubeCalculator } from './tool/ubeCalculator/entry';
|
|
33
34
|
export { waterPurifier } from './tool/waterPurifier/entry';
|
|
34
35
|
export { healthCategory } from './category';
|
|
@@ -63,6 +64,7 @@ import { peripheralVisionTrainer } from './tool/peripheralVisionTrainer/entry';
|
|
|
63
64
|
import { readingDistanceCalculator } from './tool/readingDistanceCalculator/entry';
|
|
64
65
|
import { screenDecompressionTime } from './tool/screenDecompressionTime/entry';
|
|
65
66
|
import { tinnitusReliever } from './tool/tinnitusReliever/entry';
|
|
67
|
+
import { parametricNoiseSynthesizer } from './tool/parametricNoiseSynthesizer/entry';
|
|
66
68
|
import { ubeCalculator } from './tool/ubeCalculator/entry';
|
|
67
69
|
import { waterPurifier } from './tool/waterPurifier/entry';
|
|
68
|
-
export const ALL_ENTRIES = [binauralTuner, bloodUnitConverter, bmiCalculator, amputationAdjustedBmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, breathingVisualizer, caffeineTracker, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, serumOsmolalityCalculator, correctedSodiumHyperglycemiaCalculator, fractionalExcretionSodiumCalculator, correctedReticulocyteIndex, targetHeightEstimator, daltonismSimulator, digestionStopwatch, epworthSleepinessScale, hydrationCalculator, leanBodyMassCalculator, metabolicAgeCalculator, pelliRobsonTest, peripheralVisionTrainer, readingDistanceCalculator, screenDecompressionTime, tinnitusReliever, ubeCalculator, waterPurifier];
|
|
70
|
+
export const ALL_ENTRIES = [binauralTuner, bloodUnitConverter, bmiCalculator, amputationAdjustedBmiCalculator, bodyAdiposityIndexCalculator, bodyFatPercentageCalculator, waistHipRatioCalculator, waistHeightRatioCalculator, breathingVisualizer, caffeineTracker, caloricDeficitCalculator, dailyProteinCalculator, macronutrientCalculator, tdeeCalculator, bunCreatinineCalculator, serumOsmolalityCalculator, correctedSodiumHyperglycemiaCalculator, fractionalExcretionSodiumCalculator, correctedReticulocyteIndex, targetHeightEstimator, daltonismSimulator, digestionStopwatch, epworthSleepinessScale, hydrationCalculator, leanBodyMassCalculator, metabolicAgeCalculator, pelliRobsonTest, peripheralVisionTrainer, readingDistanceCalculator, screenDecompressionTime, tinnitusReliever, parametricNoiseSynthesizer, ubeCalculator, waterPurifier];
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from './tool/bmiCalculator';
|
|
|
2
2
|
export * from './tool/amputationAdjustedBmiCalculator';
|
|
3
3
|
export * from './tool/daltonismSimulator';
|
|
4
4
|
export * from './tool/tinnitusReliever';
|
|
5
|
+
export * from './tool/parametricNoiseSynthesizer';
|
|
5
6
|
export * from './tool/breathingVisualizer';
|
|
6
7
|
export * from './tool/caffeineTracker';
|
|
7
8
|
export * from './tool/caloricDeficitCalculator';
|
|
@@ -5,7 +5,7 @@ import { healthCategory } from '../data';
|
|
|
5
5
|
describe('Tool Validation Suite', () => {
|
|
6
6
|
describe('Library Registration', () => {
|
|
7
7
|
it('should have 33 tools in ALL_TOOLS', () => {
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(34);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('healthCategory should be defined', () => {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { parametricNoiseSynthesizer } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props;
|
|
11
|
+
const content = await parametricNoiseSynthesizer.i18n[locale]?.();
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
{content && <Bibliography links={content.bibliography} />}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'Colors of noise: white, pink, brown and blue spectral models',
|
|
6
|
+
url: 'https://en.wikipedia.org/wiki/Colors_of_noise',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'Tyler RS, et al. Tinnitus retraining therapy: clinical guidelines',
|
|
10
|
+
url: 'https://pubmed.ncbi.nlm.nih.gov/23981084/',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'Hobson JA and Pace-Schott EF. The cognitive neuroscience of sleep',
|
|
14
|
+
url: 'https://pubmed.ncbi.nlm.nih.gov/11825156/',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'Zhou J, et al. Pink noise and sleep-dependent memory consolidation',
|
|
18
|
+
url: 'https://pubmed.ncbi.nlm.nih.gov/27920639/',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'World Health Organization: safe listening and hearing health',
|
|
22
|
+
url: 'https://www.who.int/activities/making-listening-safe',
|
|
23
|
+
},
|
|
24
|
+
];
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
2
|
+
|
|
3
|
+
type NoiseType = 'white' | 'pink' | 'brown' | 'blue';
|
|
4
|
+
type PinkState = [number, number, number, number, number, number, number];
|
|
5
|
+
|
|
6
|
+
interface NoiseElements {
|
|
7
|
+
canvas: HTMLCanvasElement;
|
|
8
|
+
playButton: HTMLButtonElement;
|
|
9
|
+
playIcon: HTMLElement;
|
|
10
|
+
stopIcon: HTMLElement;
|
|
11
|
+
status: HTMLElement;
|
|
12
|
+
slope: HTMLElement;
|
|
13
|
+
volume: HTMLInputElement;
|
|
14
|
+
cutoff: HTMLInputElement;
|
|
15
|
+
q: HTMLInputElement;
|
|
16
|
+
timerPicker: HTMLElement;
|
|
17
|
+
timerTrigger: HTMLButtonElement;
|
|
18
|
+
timerMenu: HTMLElement;
|
|
19
|
+
timerLabel: HTMLElement;
|
|
20
|
+
volumeOutput: HTMLOutputElement;
|
|
21
|
+
cutoffOutput: HTMLOutputElement;
|
|
22
|
+
qOutput: HTMLOutputElement;
|
|
23
|
+
timerOptions: NodeListOf<HTMLButtonElement>;
|
|
24
|
+
typeButtons: NodeListOf<HTMLButtonElement>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function required<T extends Element>(root: Element, selector: string): T {
|
|
28
|
+
const element = root.querySelector<T>(selector);
|
|
29
|
+
if (!element) throw new Error(`Missing ${selector}`);
|
|
30
|
+
return element;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function collectElements(root: Element): NoiseElements {
|
|
34
|
+
return {
|
|
35
|
+
canvas: required<HTMLCanvasElement>(root, '[data-spectrum]'),
|
|
36
|
+
playButton: required<HTMLButtonElement>(root, '[data-play]'),
|
|
37
|
+
playIcon: required<HTMLElement>(root, '.pns__play-icon--play'),
|
|
38
|
+
stopIcon: required<HTMLElement>(root, '.pns__play-icon--stop'),
|
|
39
|
+
status: required<HTMLElement>(root, '[data-status]'),
|
|
40
|
+
slope: required<HTMLElement>(root, '[data-slope]'),
|
|
41
|
+
volume: required<HTMLInputElement>(root, '[data-volume]'),
|
|
42
|
+
cutoff: required<HTMLInputElement>(root, '[data-cutoff]'),
|
|
43
|
+
q: required<HTMLInputElement>(root, '[data-q]'),
|
|
44
|
+
timerPicker: required<HTMLElement>(root, '[data-timer-picker]'),
|
|
45
|
+
timerTrigger: required<HTMLButtonElement>(root, '[data-timer-trigger]'),
|
|
46
|
+
timerMenu: required<HTMLElement>(root, '[data-timer-menu]'),
|
|
47
|
+
timerLabel: required<HTMLElement>(root, '[data-timer-label]'),
|
|
48
|
+
volumeOutput: required<HTMLOutputElement>(root, '[data-volume-output]'),
|
|
49
|
+
cutoffOutput: required<HTMLOutputElement>(root, '[data-cutoff-output]'),
|
|
50
|
+
qOutput: required<HTMLOutputElement>(root, '[data-q-output]'),
|
|
51
|
+
timerOptions: root.querySelectorAll<HTMLButtonElement>('[data-timer-option]'),
|
|
52
|
+
typeButtons: root.querySelectorAll<HTMLButtonElement>('[data-type]'),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class ParametricNoiseSynth {
|
|
57
|
+
elements: NoiseElements;
|
|
58
|
+
canvasContext: CanvasRenderingContext2D;
|
|
59
|
+
ctx: AudioContext | null = null;
|
|
60
|
+
source: AudioBufferSourceNode | null = null;
|
|
61
|
+
gain: GainNode | null = null;
|
|
62
|
+
filter: BiquadFilterNode | null = null;
|
|
63
|
+
analyser: AnalyserNode | null = null;
|
|
64
|
+
animation = 0;
|
|
65
|
+
fadeTimeout = 0;
|
|
66
|
+
timerValue = 0;
|
|
67
|
+
activeType: NoiseType = 'pink';
|
|
68
|
+
playing = false;
|
|
69
|
+
|
|
70
|
+
constructor(root: Element) {
|
|
71
|
+
this.elements = collectElements(root);
|
|
72
|
+
this.canvasContext = this.elements.canvas.getContext('2d')!;
|
|
73
|
+
this.bind();
|
|
74
|
+
this.resize();
|
|
75
|
+
this.paintIdle();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
bind() {
|
|
79
|
+
const { playButton, volume, cutoff, q, timerTrigger, timerOptions, typeButtons, timerPicker } = this.elements;
|
|
80
|
+
playButton.addEventListener('click', () => this.toggle());
|
|
81
|
+
volume.addEventListener('input', () => this.updateParams());
|
|
82
|
+
cutoff.addEventListener('input', () => this.updateParams());
|
|
83
|
+
q.addEventListener('input', () => this.updateParams());
|
|
84
|
+
timerTrigger.addEventListener('click', () => this.toggleTimerMenu());
|
|
85
|
+
timerTrigger.addEventListener('keydown', (event) => this.handleTimerTriggerKey(event));
|
|
86
|
+
timerOptions.forEach((option) => option.addEventListener('click', () => this.selectTimer(option)));
|
|
87
|
+
typeButtons.forEach((button) => this.bindTypeButton(button));
|
|
88
|
+
document.addEventListener('click', (event) => {
|
|
89
|
+
if (!timerPicker.contains(event.target as Node)) this.closeTimerMenu();
|
|
90
|
+
});
|
|
91
|
+
window.addEventListener('resize', () => this.resize());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
resize() {
|
|
95
|
+
this.elements.canvas.width = this.elements.canvas.offsetWidth;
|
|
96
|
+
this.elements.canvas.height = this.elements.canvas.offsetHeight;
|
|
97
|
+
this.paintIdle();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
bindTypeButton(button: HTMLButtonElement) {
|
|
101
|
+
button.addEventListener('click', () => {
|
|
102
|
+
this.activeType = button.dataset.type as NoiseType;
|
|
103
|
+
this.elements.typeButtons.forEach((item) => (item.dataset.active = String(item === button)));
|
|
104
|
+
this.elements.slope.textContent = `Slope: ${this.slopeFor(this.activeType)} dB/oct`;
|
|
105
|
+
if (this.playing) this.restart();
|
|
106
|
+
else this.paintIdle();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
ensureAudio() {
|
|
111
|
+
type AudioWindow = Window & { webkitAudioContext?: typeof AudioContext };
|
|
112
|
+
const AudioCtor = window.AudioContext ?? (window as AudioWindow).webkitAudioContext;
|
|
113
|
+
if (!this.ctx && AudioCtor) this.ctx = new AudioCtor();
|
|
114
|
+
if (this.ctx?.state === 'suspended') void this.ctx.resume();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
toggle() {
|
|
118
|
+
if (this.playing) this.stop();
|
|
119
|
+
else this.start();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
start() {
|
|
123
|
+
this.ensureAudio();
|
|
124
|
+
if (!this.ctx) return;
|
|
125
|
+
this.gain = this.ctx.createGain();
|
|
126
|
+
this.filter = this.ctx.createBiquadFilter();
|
|
127
|
+
this.analyser = this.ctx.createAnalyser();
|
|
128
|
+
this.source = this.ctx.createBufferSource();
|
|
129
|
+
this.source.buffer = this.makeBuffer(this.activeType);
|
|
130
|
+
this.source.loop = true;
|
|
131
|
+
this.filter.type = 'lowpass';
|
|
132
|
+
this.filter.frequency.value = Number(this.elements.cutoff.value);
|
|
133
|
+
this.filter.Q.value = Number(this.elements.q.value);
|
|
134
|
+
this.analyser.fftSize = 2048;
|
|
135
|
+
this.gain.gain.setValueAtTime(0, this.ctx.currentTime);
|
|
136
|
+
this.gain.gain.linearRampToValueAtTime(Number(this.elements.volume.value) / 100, this.ctx.currentTime + 1.2);
|
|
137
|
+
this.connectAudio();
|
|
138
|
+
this.source.start();
|
|
139
|
+
this.playing = true;
|
|
140
|
+
this.setPlayingUi();
|
|
141
|
+
this.updateParams();
|
|
142
|
+
this.armTimer();
|
|
143
|
+
this.draw();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
connectAudio() {
|
|
147
|
+
this.source!.connect(this.filter!);
|
|
148
|
+
this.filter!.connect(this.gain!);
|
|
149
|
+
this.gain!.connect(this.analyser!);
|
|
150
|
+
this.analyser!.connect(this.ctx!.destination);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
stop() {
|
|
154
|
+
window.clearTimeout(this.fadeTimeout);
|
|
155
|
+
cancelAnimationFrame(this.animation);
|
|
156
|
+
this.source?.stop();
|
|
157
|
+
this.source?.disconnect();
|
|
158
|
+
this.filter?.disconnect();
|
|
159
|
+
this.gain?.disconnect();
|
|
160
|
+
this.analyser?.disconnect();
|
|
161
|
+
this.source = null;
|
|
162
|
+
this.playing = false;
|
|
163
|
+
this.setIdleUi();
|
|
164
|
+
this.paintIdle();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
restart() {
|
|
168
|
+
this.stop();
|
|
169
|
+
this.start();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
setPlayingUi() {
|
|
173
|
+
this.elements.status.textContent = 'Playing';
|
|
174
|
+
this.elements.playIcon.hidden = true;
|
|
175
|
+
this.elements.stopIcon.hidden = false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
setIdleUi() {
|
|
179
|
+
this.elements.status.textContent = 'Ready';
|
|
180
|
+
this.elements.playIcon.hidden = false;
|
|
181
|
+
this.elements.stopIcon.hidden = true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
toggleTimerMenu() {
|
|
185
|
+
const isOpen = this.elements.timerTrigger.getAttribute('aria-expanded') === 'true';
|
|
186
|
+
if (isOpen) this.closeTimerMenu();
|
|
187
|
+
else this.openTimerMenu();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
openTimerMenu() {
|
|
191
|
+
this.elements.timerMenu.hidden = false;
|
|
192
|
+
this.elements.timerTrigger.setAttribute('aria-expanded', 'true');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
closeTimerMenu() {
|
|
196
|
+
this.elements.timerMenu.hidden = true;
|
|
197
|
+
this.elements.timerTrigger.setAttribute('aria-expanded', 'false');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
handleTimerTriggerKey(event: KeyboardEvent) {
|
|
201
|
+
if (event.key === 'Escape') {
|
|
202
|
+
this.closeTimerMenu();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
this.toggleTimerMenu();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
selectTimer(option: HTMLButtonElement) {
|
|
212
|
+
this.timerValue = Number(option.value);
|
|
213
|
+
this.elements.timerLabel.textContent = option.textContent ?? 'Off';
|
|
214
|
+
this.elements.timerOptions.forEach((item) => item.setAttribute('aria-selected', String(item === option)));
|
|
215
|
+
this.closeTimerMenu();
|
|
216
|
+
this.armTimer();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
armTimer() {
|
|
220
|
+
window.clearTimeout(this.fadeTimeout);
|
|
221
|
+
const minutes = this.timerValue;
|
|
222
|
+
if (!minutes || !this.playing || !this.ctx || !this.gain) return;
|
|
223
|
+
this.fadeTimeout = window.setTimeout(() => this.fadeOut(), minutes * 60 * 1000);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fadeOut() {
|
|
227
|
+
if (!this.ctx || !this.gain) return;
|
|
228
|
+
this.elements.status.textContent = 'Fading out';
|
|
229
|
+
this.gain.gain.cancelScheduledValues(this.ctx.currentTime);
|
|
230
|
+
this.gain.gain.setValueAtTime(this.gain.gain.value, this.ctx.currentTime);
|
|
231
|
+
this.gain.gain.linearRampToValueAtTime(0.0001, this.ctx.currentTime + 24);
|
|
232
|
+
window.setTimeout(() => this.stop(), 24500);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
updateParams() {
|
|
236
|
+
const volumeValue = Number(this.elements.volume.value);
|
|
237
|
+
const cutoffValue = Number(this.elements.cutoff.value);
|
|
238
|
+
const qValue = Number(this.elements.q.value);
|
|
239
|
+
this.elements.volumeOutput.textContent = `${volumeValue}%`;
|
|
240
|
+
this.elements.cutoffOutput.textContent = `${cutoffValue} Hz`;
|
|
241
|
+
this.elements.qOutput.textContent = qValue.toFixed(1);
|
|
242
|
+
if (!this.ctx) return;
|
|
243
|
+
this.gain?.gain.setTargetAtTime(volumeValue / 100, this.ctx.currentTime, 0.08);
|
|
244
|
+
this.filter?.frequency.setTargetAtTime(cutoffValue, this.ctx.currentTime, 0.08);
|
|
245
|
+
this.filter?.Q.setTargetAtTime(qValue, this.ctx.currentTime, 0.08);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
makeBuffer(type: NoiseType) {
|
|
249
|
+
const sampleRate = this.ctx!.sampleRate;
|
|
250
|
+
const buffer = this.ctx!.createBuffer(1, sampleRate * 4, sampleRate);
|
|
251
|
+
const data = buffer.getChannelData(0);
|
|
252
|
+
this.fillNoiseBuffer(data, type);
|
|
253
|
+
return buffer;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
fillNoiseBuffer(data: Float32Array, type: NoiseType) {
|
|
257
|
+
let pinkState: PinkState = [0, 0, 0, 0, 0, 0, 0];
|
|
258
|
+
let brownLast = 0;
|
|
259
|
+
let blueLast = 0;
|
|
260
|
+
for (let i = 0; i < data.length; i++) {
|
|
261
|
+
const white = Math.random() * 2 - 1;
|
|
262
|
+
if (type === 'white') data[i] = white * 0.42;
|
|
263
|
+
if (type === 'pink') [data[i], pinkState] = this.pinkSample(white, pinkState);
|
|
264
|
+
if (type === 'brown') {
|
|
265
|
+
brownLast = (brownLast + 0.02 * white) / 1.02;
|
|
266
|
+
data[i] = brownLast * 3.2;
|
|
267
|
+
}
|
|
268
|
+
if (type === 'blue') {
|
|
269
|
+
data[i] = (white - blueLast) * 0.38;
|
|
270
|
+
blueLast = white;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
pinkSample(white: number, state: PinkState): [number, PinkState] {
|
|
276
|
+
let [b0, b1, b2, b3, b4, b5] = state;
|
|
277
|
+
const b6 = state[6];
|
|
278
|
+
b0 = 0.99886 * b0 + white * 0.0555179;
|
|
279
|
+
b1 = 0.99332 * b1 + white * 0.0750759;
|
|
280
|
+
b2 = 0.969 * b2 + white * 0.153852;
|
|
281
|
+
b3 = 0.8665 * b3 + white * 0.3104856;
|
|
282
|
+
b4 = 0.55 * b4 + white * 0.5329522;
|
|
283
|
+
b5 = -0.7616 * b5 - white * 0.016898;
|
|
284
|
+
const sample = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362) * 0.09;
|
|
285
|
+
return [sample, [b0, b1, b2, b3, b4, b5, white * 0.115926]];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
slopeFor(type: NoiseType) {
|
|
289
|
+
return { white: 0, pink: -3, brown: -6, blue: 3 }[type];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
draw() {
|
|
293
|
+
if (!this.playing || !this.analyser) return;
|
|
294
|
+
this.animation = requestAnimationFrame(() => this.draw());
|
|
295
|
+
const data = new Uint8Array(this.analyser.frequencyBinCount);
|
|
296
|
+
this.analyser.getByteFrequencyData(data);
|
|
297
|
+
this.paint(data);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
paint(data?: Uint8Array) {
|
|
301
|
+
const { canvas } = this.elements;
|
|
302
|
+
const ctx = this.canvasContext;
|
|
303
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
304
|
+
ctx.strokeStyle = this.makeGradient(canvas.width, canvas.height);
|
|
305
|
+
ctx.lineWidth = 2;
|
|
306
|
+
ctx.beginPath();
|
|
307
|
+
this.drawSpectrumPath(data);
|
|
308
|
+
ctx.stroke();
|
|
309
|
+
ctx.lineTo(canvas.width, canvas.height);
|
|
310
|
+
ctx.lineTo(0, canvas.height);
|
|
311
|
+
ctx.fillStyle = 'rgba(56, 189, 248, 0.12)';
|
|
312
|
+
ctx.fill();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
makeGradient(width: number, height: number) {
|
|
316
|
+
const gradient = this.canvasContext.createLinearGradient(0, 0, width, height);
|
|
317
|
+
gradient.addColorStop(0, '#38bdf8');
|
|
318
|
+
gradient.addColorStop(0.55, '#22c55e');
|
|
319
|
+
gradient.addColorStop(1, '#f59e0b');
|
|
320
|
+
return gradient;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
drawSpectrumPath(data?: Uint8Array) {
|
|
324
|
+
const { canvas } = this.elements;
|
|
325
|
+
const points = data ?? new Uint8Array(96).map((_, i) => 120 - i * 0.8 + Math.sin(i / 4) * 10);
|
|
326
|
+
for (let i = 0; i < points.length; i++) {
|
|
327
|
+
const x = (i / (points.length - 1)) * canvas.width;
|
|
328
|
+
const y = canvas.height - (points[i] / 255) * canvas.height * 0.86 - canvas.height * 0.07;
|
|
329
|
+
if (i === 0) this.canvasContext.moveTo(x, y);
|
|
330
|
+
else this.canvasContext.lineTo(x, y);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
paintIdle() {
|
|
335
|
+
this.elements.slope.textContent = `Slope: ${this.slopeFor(this.activeType)} dB/oct`;
|
|
336
|
+
this.paint();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function initParametricNoiseSynthesizer(root: Element) {
|
|
341
|
+
new ParametricNoiseSynth(root);
|
|
342
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Icon } from 'astro-icon/components';
|
|
3
|
+
import './parametric-pink-brown-noise-synthesizer.css';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
import type { ParametricNoiseSynthesizerUI } from './ui';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
locale?: KnownLocale;
|
|
9
|
+
ui?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const t = (Astro.props.ui ?? {}) as ParametricNoiseSynthesizerUI;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<div class="pns" data-noise-synth>
|
|
16
|
+
<section class="pns__scope" aria-label="Noise spectrum visualizer">
|
|
17
|
+
<canvas class="pns__canvas" data-spectrum></canvas>
|
|
18
|
+
<div class="pns__readout">
|
|
19
|
+
<span data-status>{t.statusIdle ?? 'Ready'}</span>
|
|
20
|
+
<strong data-slope>{t.spectralSlope ?? 'Slope'}: -3 dB/oct</strong>
|
|
21
|
+
</div>
|
|
22
|
+
<button class="pns__play" type="button" data-play aria-label={t.play ?? 'Play'}>
|
|
23
|
+
<span class="pns__play-icon pns__play-icon--play"><Icon name="mdi:play" /></span>
|
|
24
|
+
<span class="pns__play-icon pns__play-icon--stop" hidden><Icon name="mdi:stop" /></span>
|
|
25
|
+
</button>
|
|
26
|
+
</section>
|
|
27
|
+
|
|
28
|
+
<section class="pns__desk" aria-label="Parametric noise controls">
|
|
29
|
+
<div class="pns__types" role="group" aria-label="Noise type">
|
|
30
|
+
<button type="button" class="pns__type" data-type="pink" data-active="true">{t.typePink ?? 'Pink'}</button>
|
|
31
|
+
<button type="button" class="pns__type" data-type="brown" data-active="false">{t.typeBrown ?? 'Brown'}</button>
|
|
32
|
+
<button type="button" class="pns__type" data-type="white" data-active="false">{t.typeWhite ?? 'White'}</button>
|
|
33
|
+
<button type="button" class="pns__type" data-type="blue" data-active="false">{t.typeBlue ?? 'Blue'}</button>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div class="pns__mix">
|
|
37
|
+
<label class="pns__fader">
|
|
38
|
+
<span>{t.labelVolume ?? 'Volume'}</span>
|
|
39
|
+
<output data-volume-output>34%</output>
|
|
40
|
+
<input data-volume type="range" min="0" max="90" value="34" />
|
|
41
|
+
</label>
|
|
42
|
+
<label class="pns__fader">
|
|
43
|
+
<span>{t.labelCutoff ?? 'Cutoff'}</span>
|
|
44
|
+
<output data-cutoff-output>3200 Hz</output>
|
|
45
|
+
<input data-cutoff type="range" min="100" max="10000" step="10" value="3200" />
|
|
46
|
+
</label>
|
|
47
|
+
<label class="pns__fader">
|
|
48
|
+
<span>{t.labelResonance ?? 'Q'}</span>
|
|
49
|
+
<output data-q-output>0.7</output>
|
|
50
|
+
<input data-q type="range" min="0.1" max="12" step="0.1" value="0.7" />
|
|
51
|
+
</label>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div class="pns__timer">
|
|
55
|
+
<span>{t.labelTimer ?? 'Sleep timer'}</span>
|
|
56
|
+
<div class="pns__timer-picker" data-timer-picker>
|
|
57
|
+
<button class="pns__timer-trigger" type="button" data-timer-trigger aria-haspopup="listbox" aria-expanded="false">
|
|
58
|
+
<span data-timer-label>{t.timerOff ?? 'Off'}</span>
|
|
59
|
+
<span class="pns__timer-chevron" aria-hidden="true"><Icon name="mdi:chevron-down" /></span>
|
|
60
|
+
</button>
|
|
61
|
+
<div class="pns__timer-menu" role="listbox" data-timer-menu hidden>
|
|
62
|
+
<button type="button" role="option" data-timer-option value="0" aria-selected="true">{t.timerOff ?? 'Off'}</button>
|
|
63
|
+
<button type="button" role="option" data-timer-option value="15" aria-selected="false">{t.timer15 ?? '15 min'}</button>
|
|
64
|
+
<button type="button" role="option" data-timer-option value="30" aria-selected="false">{t.timer30 ?? '30 min'}</button>
|
|
65
|
+
<button type="button" role="option" data-timer-option value="45" aria-selected="false">{t.timer45 ?? '45 min'}</button>
|
|
66
|
+
<button type="button" role="option" data-timer-option value="60" aria-selected="false">{t.timer60 ?? '60 min'}</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</section>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<script>
|
|
74
|
+
import { initParametricNoiseSynthesizer } from './client';
|
|
75
|
+
|
|
76
|
+
document.querySelectorAll('[data-noise-synth]').forEach(initParametricNoiseSynthesizer);
|
|
77
|
+
</script>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { HealthToolEntry } from '../../types';
|
|
2
|
+
import type { ParametricNoiseSynthesizerUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export const parametricNoiseSynthesizer: HealthToolEntry<ParametricNoiseSynthesizerUI> = {
|
|
5
|
+
id: 'parametricNoiseSynthesizer',
|
|
6
|
+
icons: {
|
|
7
|
+
bg: 'mdi:waveform',
|
|
8
|
+
fg: 'mdi:tune',
|
|
9
|
+
},
|
|
10
|
+
i18n: {
|
|
11
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
12
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
13
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
14
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
15
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
16
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
17
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
18
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
19
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
20
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
21
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
22
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
23
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
24
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
25
|
+
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
26
|
+
},
|
|
27
|
+
};
|