@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.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +3 -1
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +1 -1
  6. package/src/tests/tool_validation.test.ts +1 -1
  7. package/src/tool/parametricNoiseSynthesizer/bibliography.astro +14 -0
  8. package/src/tool/parametricNoiseSynthesizer/bibliography.ts +24 -0
  9. package/src/tool/parametricNoiseSynthesizer/client.ts +342 -0
  10. package/src/tool/parametricNoiseSynthesizer/component.astro +77 -0
  11. package/src/tool/parametricNoiseSynthesizer/entry.ts +27 -0
  12. package/src/tool/parametricNoiseSynthesizer/i18n/de.ts +295 -0
  13. package/src/tool/parametricNoiseSynthesizer/i18n/en.ts +295 -0
  14. package/src/tool/parametricNoiseSynthesizer/i18n/es.ts +295 -0
  15. package/src/tool/parametricNoiseSynthesizer/i18n/fr.ts +295 -0
  16. package/src/tool/parametricNoiseSynthesizer/i18n/id.ts +295 -0
  17. package/src/tool/parametricNoiseSynthesizer/i18n/it.ts +295 -0
  18. package/src/tool/parametricNoiseSynthesizer/i18n/ja.ts +295 -0
  19. package/src/tool/parametricNoiseSynthesizer/i18n/ko.ts +295 -0
  20. package/src/tool/parametricNoiseSynthesizer/i18n/nl.ts +295 -0
  21. package/src/tool/parametricNoiseSynthesizer/i18n/pl.ts +295 -0
  22. package/src/tool/parametricNoiseSynthesizer/i18n/pt.ts +295 -0
  23. package/src/tool/parametricNoiseSynthesizer/i18n/ru.ts +295 -0
  24. package/src/tool/parametricNoiseSynthesizer/i18n/sv.ts +295 -0
  25. package/src/tool/parametricNoiseSynthesizer/i18n/tr.ts +295 -0
  26. package/src/tool/parametricNoiseSynthesizer/i18n/zh.ts +295 -0
  27. package/src/tool/parametricNoiseSynthesizer/index.ts +9 -0
  28. package/src/tool/parametricNoiseSynthesizer/logic.ts +12 -0
  29. package/src/tool/parametricNoiseSynthesizer/parametric-pink-brown-noise-synthesizer.css +300 -0
  30. package/src/tool/parametricNoiseSynthesizer/seo.astro +15 -0
  31. package/src/tool/parametricNoiseSynthesizer/ui.ts +21 -0
  32. package/src/tools.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-health",
3
- "version": "1.31.0",
3
+ "version": "1.32.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -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';
@@ -36,6 +36,6 @@ describe('Locale Completeness Validation', () => {
36
36
  });
37
37
 
38
38
  it('all tools registered', () => {
39
- expect(ALL_TOOLS.length).toBe(33);
39
+ expect(ALL_TOOLS.length).toBe(34);
40
40
  });
41
41
  });
@@ -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(33);
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
+ };