@jjlmoya/utils-forensic-science 1.12.0 → 1.14.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 (169) hide show
  1. package/package.json +2 -1
  2. package/src/category/index.ts +63 -59
  3. package/src/category/seo.astro +1 -1
  4. package/src/components/PreviewNavSidebar.astro +1 -1
  5. package/src/components/PreviewToolbar.astro +1 -1
  6. package/src/data.ts +1 -1
  7. package/src/entries.ts +74 -68
  8. package/src/env.d.ts +1 -1
  9. package/src/index.ts +33 -31
  10. package/src/pages/[locale]/[slug].astro +161 -159
  11. package/src/pages/[locale].astro +8 -5
  12. package/src/pages/index.astro +1 -1
  13. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  14. package/src/tests/diacritics_density.test.ts +1 -1
  15. package/src/tests/faq_count.test.ts +19 -19
  16. package/src/tests/i18n_coverage.test.ts +36 -36
  17. package/src/tests/inverted_punctuation.test.ts +1 -1
  18. package/src/tests/locale_completeness.test.ts +2 -2
  19. package/src/tests/mocks/astro_mock.js +1 -1
  20. package/src/tests/no_h1_in_components.test.ts +1 -1
  21. package/src/tests/script_density.test.ts +94 -94
  22. package/src/tests/seo_length.test.ts +46 -46
  23. package/src/tests/seo_parity.test.ts +2 -7
  24. package/src/tests/seo_translation_completeness.test.ts +68 -0
  25. package/src/tests/seo_wellformed_export.test.ts +65 -0
  26. package/src/tests/tool_validation.test.ts +2 -2
  27. package/src/tests/translation_copy.test.ts +23 -24
  28. package/src/tool/bloodstain-pattern-origin-analyzer/component.astro +9 -9
  29. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/zh.ts +1 -1
  30. package/src/tool/dna-profile-match-probability-lab/bibliography.astro +14 -0
  31. package/src/tool/dna-profile-match-probability-lab/bibliography.ts +16 -0
  32. package/src/tool/dna-profile-match-probability-lab/component.astro +121 -0
  33. package/src/tool/dna-profile-match-probability-lab/controller.ts +159 -0
  34. package/src/tool/dna-profile-match-probability-lab/dna-profile-match-probability-lab.css +485 -0
  35. package/src/tool/dna-profile-match-probability-lab/dom-views.ts +93 -0
  36. package/src/tool/dna-profile-match-probability-lab/entry.ts +29 -0
  37. package/src/tool/dna-profile-match-probability-lab/evaluator.ts +19 -0
  38. package/src/tool/dna-profile-match-probability-lab/i18n/de.ts +43 -0
  39. package/src/tool/dna-profile-match-probability-lab/i18n/en.ts +196 -0
  40. package/src/tool/dna-profile-match-probability-lab/i18n/es.ts +40 -0
  41. package/src/tool/dna-profile-match-probability-lab/i18n/fr.ts +10 -0
  42. package/src/tool/dna-profile-match-probability-lab/i18n/id.ts +41 -0
  43. package/src/tool/dna-profile-match-probability-lab/i18n/it.ts +10 -0
  44. package/src/tool/dna-profile-match-probability-lab/i18n/ja.ts +40 -0
  45. package/src/tool/dna-profile-match-probability-lab/i18n/ko.ts +40 -0
  46. package/src/tool/dna-profile-match-probability-lab/i18n/nl.ts +29 -0
  47. package/src/tool/dna-profile-match-probability-lab/i18n/pl.ts +10 -0
  48. package/src/tool/dna-profile-match-probability-lab/i18n/pt.ts +41 -0
  49. package/src/tool/dna-profile-match-probability-lab/i18n/ru.ts +29 -0
  50. package/src/tool/dna-profile-match-probability-lab/i18n/sv.ts +41 -0
  51. package/src/tool/dna-profile-match-probability-lab/i18n/tr.ts +10 -0
  52. package/src/tool/dna-profile-match-probability-lab/i18n/zh.ts +29 -0
  53. package/src/tool/dna-profile-match-probability-lab/index.ts +11 -0
  54. package/src/tool/dna-profile-match-probability-lab/localize.ts +107 -0
  55. package/src/tool/dna-profile-match-probability-lab/logic.test.ts +56 -0
  56. package/src/tool/dna-profile-match-probability-lab/logic.ts +139 -0
  57. package/src/tool/dna-profile-match-probability-lab/seo.astro +15 -0
  58. package/src/tool/dna-profile-match-probability-lab/storage.ts +26 -0
  59. package/src/tool/dna-profile-match-probability-lab/ui.ts +3 -0
  60. package/src/tool/fire-pattern-origin-analyzer/component.astro +1 -1
  61. package/src/tool/fire-pattern-origin-analyzer/i18n/zh.ts +5 -1
  62. package/src/tool/fire-pattern-origin-analyzer/logic.ts +9 -2
  63. package/src/tool/fire-pattern-origin-analyzer/view-bindings.ts +8 -4
  64. package/src/tool/fire-pattern-origin-analyzer/view-model.ts +6 -2
  65. package/src/tool/forensic-age-estimator/bibliography.astro +1 -1
  66. package/src/tool/forensic-age-estimator/component.astro +1 -1
  67. package/src/tool/forensic-age-estimator/entry.ts +17 -17
  68. package/src/tool/forensic-age-estimator/i18n/de.ts +244 -244
  69. package/src/tool/forensic-age-estimator/i18n/en.ts +244 -244
  70. package/src/tool/forensic-age-estimator/i18n/es.ts +244 -244
  71. package/src/tool/forensic-age-estimator/i18n/fr.ts +244 -244
  72. package/src/tool/forensic-age-estimator/i18n/id.ts +244 -244
  73. package/src/tool/forensic-age-estimator/i18n/it.ts +244 -244
  74. package/src/tool/forensic-age-estimator/i18n/nl.ts +244 -244
  75. package/src/tool/forensic-age-estimator/i18n/pl.ts +244 -244
  76. package/src/tool/forensic-age-estimator/i18n/pt.ts +244 -244
  77. package/src/tool/forensic-age-estimator/i18n/ru.ts +244 -244
  78. package/src/tool/forensic-age-estimator/i18n/sv.ts +244 -244
  79. package/src/tool/forensic-age-estimator/i18n/tr.ts +244 -244
  80. package/src/tool/forensic-age-estimator/i18n/zh.ts +245 -245
  81. package/src/tool/forensic-age-estimator/index.ts +1 -1
  82. package/src/tool/forensic-age-estimator/seo.astro +1 -1
  83. package/src/tool/forensic-blood-test-simulator/component.astro +1 -1
  84. package/src/tool/forensic-fiber-comparison-microscope/component.astro +1 -1
  85. package/src/tool/forensic-fiber-comparison-microscope/render.ts +1 -1
  86. package/src/tool/forensic-fiber-comparison-microscope/view.ts +1 -1
  87. package/src/tool/forensic-fingerprint-minutiae-identifier/component.astro +2 -2
  88. package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/ja.ts +2 -1
  89. package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/zh.ts +2 -0
  90. package/src/tool/forensic-fingerprint-minutiae-identifier/renderer.ts +9 -8
  91. package/src/tool/forensic-glass-becke-line-simulator/component.astro +1 -1
  92. package/src/tool/forensic-glass-becke-line-simulator/view.ts +8 -6
  93. package/src/tool/forensic-image-authenticity-analyzer/component.astro +1 -1
  94. package/src/tool/forensic-image-authenticity-analyzer/i18n/de.ts +105 -105
  95. package/src/tool/forensic-image-authenticity-analyzer/i18n/en.ts +105 -105
  96. package/src/tool/forensic-image-authenticity-analyzer/i18n/es.ts +105 -105
  97. package/src/tool/forensic-image-authenticity-analyzer/i18n/fr.ts +105 -105
  98. package/src/tool/forensic-image-authenticity-analyzer/i18n/id.ts +95 -90
  99. package/src/tool/forensic-image-authenticity-analyzer/i18n/it.ts +105 -105
  100. package/src/tool/forensic-image-authenticity-analyzer/i18n/ja.ts +7 -2
  101. package/src/tool/forensic-image-authenticity-analyzer/i18n/ko.ts +4 -1
  102. package/src/tool/forensic-image-authenticity-analyzer/i18n/nl.ts +95 -92
  103. package/src/tool/forensic-image-authenticity-analyzer/i18n/pl.ts +95 -92
  104. package/src/tool/forensic-image-authenticity-analyzer/i18n/pt.ts +105 -105
  105. package/src/tool/forensic-image-authenticity-analyzer/i18n/ru.ts +97 -92
  106. package/src/tool/forensic-image-authenticity-analyzer/i18n/sv.ts +97 -93
  107. package/src/tool/forensic-image-authenticity-analyzer/i18n/tr.ts +98 -93
  108. package/src/tool/forensic-image-authenticity-analyzer/i18n/zh.ts +11 -0
  109. package/src/tool/forensic-microcrystal-drug-simulator/component.astro +1 -1
  110. package/src/tool/forensic-sex-determinator/component.astro +3 -2
  111. package/src/tool/forensic-stature-estimator/component.astro +1 -1
  112. package/src/tool/forensic-stature-estimator/i18n/id.ts +4 -0
  113. package/src/tool/forensic-stature-estimator/i18n/nl.ts +4 -0
  114. package/src/tool/forensic-stature-estimator/i18n/ru.ts +4 -0
  115. package/src/tool/forensic-tlc-ink-simulator/component.astro +1 -1
  116. package/src/tool/forensic-tlc-ink-simulator/i18n/zh.ts +1 -0
  117. package/src/tool/forensic-toolmark-striation-matcher/component.astro +1 -1
  118. package/src/tool/forensic-toolmark-striation-matcher/logic.ts +1 -1
  119. package/src/tool/forensic-toolmark-striation-matcher/renderer.ts +7 -6
  120. package/src/tool/forensic-toolmark-striation-matcher/view.ts +19 -20
  121. package/src/tool/gsr-dispersion-calculator/component.astro +1 -1
  122. package/src/tool/gsr-dispersion-calculator/logic.ts +1 -1
  123. package/src/tool/time-of-death-algor-mortis-calculator/entry.ts +2 -0
  124. package/src/tool/voice-spectrogram-analyzer/audio-runtime.ts +75 -0
  125. package/src/tool/voice-spectrogram-analyzer/bibliography.astro +14 -0
  126. package/src/tool/voice-spectrogram-analyzer/bibliography.ts +16 -0
  127. package/src/tool/voice-spectrogram-analyzer/component.astro +116 -0
  128. package/src/tool/voice-spectrogram-analyzer/controller.ts +219 -0
  129. package/src/tool/voice-spectrogram-analyzer/dom-views.ts +204 -0
  130. package/src/tool/voice-spectrogram-analyzer/entry.ts +31 -0
  131. package/src/tool/voice-spectrogram-analyzer/evaluator.ts +13 -0
  132. package/src/tool/voice-spectrogram-analyzer/fft.ts +64 -0
  133. package/src/tool/voice-spectrogram-analyzer/i18n/de.ts +135 -0
  134. package/src/tool/voice-spectrogram-analyzer/i18n/en.ts +122 -0
  135. package/src/tool/voice-spectrogram-analyzer/i18n/es.ts +122 -0
  136. package/src/tool/voice-spectrogram-analyzer/i18n/fr.ts +135 -0
  137. package/src/tool/voice-spectrogram-analyzer/i18n/id.ts +136 -0
  138. package/src/tool/voice-spectrogram-analyzer/i18n/it.ts +135 -0
  139. package/src/tool/voice-spectrogram-analyzer/i18n/ja.ts +135 -0
  140. package/src/tool/voice-spectrogram-analyzer/i18n/ko.ts +135 -0
  141. package/src/tool/voice-spectrogram-analyzer/i18n/nl.ts +136 -0
  142. package/src/tool/voice-spectrogram-analyzer/i18n/pl.ts +136 -0
  143. package/src/tool/voice-spectrogram-analyzer/i18n/pt.ts +135 -0
  144. package/src/tool/voice-spectrogram-analyzer/i18n/ru.ts +136 -0
  145. package/src/tool/voice-spectrogram-analyzer/i18n/sv.ts +136 -0
  146. package/src/tool/voice-spectrogram-analyzer/i18n/tr.ts +136 -0
  147. package/src/tool/voice-spectrogram-analyzer/i18n/zh.ts +135 -0
  148. package/src/tool/voice-spectrogram-analyzer/index.ts +11 -0
  149. package/src/tool/voice-spectrogram-analyzer/logic.test.ts +46 -0
  150. package/src/tool/voice-spectrogram-analyzer/logic.ts +163 -0
  151. package/src/tool/voice-spectrogram-analyzer/seo.astro +15 -0
  152. package/src/tool/voice-spectrogram-analyzer/storage.ts +33 -0
  153. package/src/tool/voice-spectrogram-analyzer/ui.ts +49 -0
  154. package/src/tool/voice-spectrogram-analyzer/voice-spectrogram-analyzer.css +547 -0
  155. package/src/tool/widmark-alcohol-simulator/component.astro +1 -1
  156. package/src/tool/widmark-alcohol-simulator/i18n/de.ts +5 -0
  157. package/src/tool/widmark-alcohol-simulator/i18n/fr.ts +2 -1
  158. package/src/tool/widmark-alcohol-simulator/i18n/id.ts +1 -0
  159. package/src/tool/widmark-alcohol-simulator/i18n/it.ts +4 -1
  160. package/src/tool/widmark-alcohol-simulator/i18n/ja.ts +4 -1
  161. package/src/tool/widmark-alcohol-simulator/i18n/nl.ts +5 -1
  162. package/src/tool/widmark-alcohol-simulator/i18n/pl.ts +5 -1
  163. package/src/tool/widmark-alcohol-simulator/i18n/pt.ts +5 -1
  164. package/src/tool/widmark-alcohol-simulator/i18n/ru.ts +4 -1
  165. package/src/tool/widmark-alcohol-simulator/i18n/sv.ts +5 -1
  166. package/src/tool/widmark-alcohol-simulator/i18n/tr.ts +4 -1
  167. package/src/tool/widmark-alcohol-simulator/i18n/zh.ts +4 -1
  168. package/src/tools.ts +39 -35
  169. package/src/types.ts +4 -4
@@ -0,0 +1,16 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Koenig, Dunn and Lacy (1946) - The Sound Spectrograph, Journal of the Acoustical Society of America',
6
+ url: 'https://doi.org/10.1121/1.1916342'
7
+ },
8
+ {
9
+ name: 'Peterson and Barney (1952) - Control Methods Used in a Study of the Vowels, Journal of the Acoustical Society of America',
10
+ url: 'https://doi.org/10.1121/1.1906875'
11
+ },
12
+ {
13
+ name: 'Hillenbrand, Getty, Clark and Wheeler (1995) - Acoustic Characteristics of American English Vowels, Journal of the Acoustical Society of America',
14
+ url: 'https://doi.org/10.1121/1.411872'
15
+ }
16
+ ];
@@ -0,0 +1,116 @@
1
+ ---
2
+ import './voice-spectrogram-analyzer.css';
3
+ import type { VoiceSpectrogramUI } from './ui';
4
+
5
+ interface Props {
6
+ ui: VoiceSpectrogramUI;
7
+ }
8
+
9
+ const { ui } = Astro.props;
10
+ ---
11
+
12
+ <div class="voice-spectrogram" data-voice-spectrogram>
13
+ <div class="voice-spectrogram-privacy">
14
+ <span>{ui.privacyBadge}</span>
15
+ <p>{ui.privacyNote}</p>
16
+ </div>
17
+
18
+ <section class="voice-spectrogram-load" aria-labelledby="voice-load-heading">
19
+ <div class="voice-spectrogram-section-intro">
20
+ <p class="voice-spectrogram-kicker">{ui.loadHeading}</p>
21
+ <span>{ui.presetHint}</span>
22
+ </div>
23
+ <div class="voice-spectrogram-samples">
24
+ {(['a', 'b'] as const).map((key) => (
25
+ <article class={`voice-spectrogram-sample voice-spectrogram-sample-${key}`} data-card={key} data-state="ready">
26
+ <div class="voice-spectrogram-sample-index">{key === 'a' ? ui.sampleALabel : ui.sampleBLabel}</div>
27
+ <div class="voice-spectrogram-drop" data-drop={key}>
28
+ <div>
29
+ <strong data-name={key}>{key === 'a' ? ui.presetWarmLabel : ui.presetBrightLabel}</strong>
30
+ <span data-status={key}>{ui.readySampleLabel}</span>
31
+ </div>
32
+ <label class="voice-spectrogram-file">
33
+ <span>{ui.chooseFileLabel}</span>
34
+ <input type="file" accept="audio/*" data-file={key} />
35
+ </label>
36
+ <small>{ui.dropHint}</small>
37
+ </div>
38
+ <div class="voice-spectrogram-sample-foot">
39
+ <span data-duration={key}>{ui.durationLabel} 0:02</span>
40
+ <div class="voice-spectrogram-presets">
41
+ <button type="button" data-preset="warm" data-track={key}>{ui.presetWarmLabel}</button>
42
+ <button type="button" data-preset="bright" data-track={key}>{ui.presetBrightLabel}</button>
43
+ </div>
44
+ </div>
45
+ </article>
46
+ ))}
47
+ </div>
48
+ </section>
49
+
50
+ <section class="voice-spectrogram-instrument" aria-label={ui.stageLabel}>
51
+ <div class="voice-spectrogram-instrument-head">
52
+ <div>
53
+ <span class="voice-spectrogram-live-dot"></span>
54
+ <strong data-stage-status>{ui.statusReadyLabel}</strong>
55
+ </div>
56
+ <div class="voice-spectrogram-control-clusters">
57
+ <div class="voice-spectrogram-segmented" aria-label={ui.ceilingHeading}>
58
+ <span>{ui.ceilingHeading}</span>
59
+ <button type="button" data-ceiling="4000">{ui.ceilingFourLabel}</button>
60
+ <button type="button" data-ceiling="6000" class="is-active">{ui.ceilingSixLabel}</button>
61
+ <button type="button" data-ceiling="8000">{ui.ceilingEightLabel}</button>
62
+ </div>
63
+ <div class="voice-spectrogram-segmented" aria-label={ui.stageLabel}>
64
+ <button type="button" data-view="mirror" class="is-active">{ui.mirrorViewLabel}</button>
65
+ <button type="button" data-view="split">{ui.splitViewLabel}</button>
66
+ </div>
67
+ </div>
68
+ </div>
69
+
70
+ <div class="voice-spectrogram-plate">
71
+ <canvas aria-label={`${ui.stageLabel}. ${ui.educationalNote}`}></canvas>
72
+ <div class="voice-spectrogram-plate-glow"></div>
73
+ </div>
74
+
75
+ <div class="voice-spectrogram-transport">
76
+ <button type="button" data-play="a"><span>A</span>{ui.playALabel}</button>
77
+ <button type="button" data-stop class="voice-spectrogram-stop">{ui.stopLabel}</button>
78
+ <button type="button" data-play="b"><span>B</span>{ui.playBLabel}</button>
79
+ </div>
80
+ <div class="voice-spectrogram-legend">
81
+ <span><i class="voice-spectrogram-swatch voice-spectrogram-swatch-a"></i>{ui.sampleALabel}</span>
82
+ <span><i class="voice-spectrogram-swatch voice-spectrogram-swatch-b"></i>{ui.sampleBLabel}</span>
83
+ <span><i class="voice-spectrogram-line"></i>{ui.formantLegendLabel}</span>
84
+ <span>{ui.intensityLegendLabel}</span>
85
+ </div>
86
+ </section>
87
+
88
+ <section class="voice-spectrogram-comparison" aria-labelledby="voice-comparison-heading">
89
+ <div class="voice-spectrogram-comparison-copy">
90
+ <p class="voice-spectrogram-kicker" id="voice-comparison-heading">{ui.comparisonHeading}</p>
91
+ <p>{ui.comparisonNote}</p>
92
+ </div>
93
+ <div class="voice-spectrogram-formants">
94
+ {[ui.formantOneLabel, ui.formantTwoLabel, ui.formantThreeLabel].map((label, index) => (
95
+ <article>
96
+ <span class="voice-spectrogram-formant-index">F{index + 1}</span>
97
+ <strong>{label}</strong>
98
+ <dl>
99
+ <div><dt>A</dt><dd data-value={`a-${index}`}>{ui.unavailableLabel}</dd></div>
100
+ <div><dt>B</dt><dd data-value={`b-${index}`}>{ui.unavailableLabel}</dd></div>
101
+ <div><dt>{ui.differenceLabel}</dt><dd data-value={`d-${index}`}>{ui.unavailableLabel}</dd></div>
102
+ </dl>
103
+ </article>
104
+ ))}
105
+ </div>
106
+ </section>
107
+
108
+ <p class="voice-spectrogram-educational">{ui.educationalNote}</p>
109
+ <script is:inline type="application/json" data-voice-content set:html={JSON.stringify(ui)}></script>
110
+ </div>
111
+
112
+ <script>
113
+ import { mountVoiceSpectrogram } from './controller';
114
+
115
+ document.querySelectorAll<HTMLElement>('[data-voice-spectrogram]').forEach(mountVoiceSpectrogram);
116
+ </script>
@@ -0,0 +1,219 @@
1
+ import { AudioPlayer, decodeAudioFile } from './audio-runtime';
2
+ import type { DecodedAudio } from './audio-runtime';
3
+ import { renderSpectrogram } from './dom-views';
4
+ import { evaluateStage } from './evaluator';
5
+ import type { SampleState } from './evaluator';
6
+ import { analyzeSamples, compareFormants, createSyntheticVowel, formatDuration } from './logic';
7
+ import type { SpectrogramAnalysis, SyntheticVowel } from './logic';
8
+ import { loadSettings, saveSettings } from './storage';
9
+ import type { SpectrogramSettings } from './storage';
10
+ import type { VoiceSpectrogramUI } from './ui';
11
+
12
+ type TrackKey = 'a' | 'b';
13
+
14
+ interface Track extends DecodedAudio {
15
+ name: string;
16
+ state: SampleState;
17
+ analysis: SpectrogramAnalysis;
18
+ error: string;
19
+ }
20
+
21
+ interface AppState {
22
+ tracks: Record<TrackKey, Track>;
23
+ settings: SpectrogramSettings;
24
+ active: TrackKey | null;
25
+ progress: number;
26
+ }
27
+
28
+ interface ViewContext {
29
+ root: HTMLElement;
30
+ canvas: HTMLCanvasElement;
31
+ state: AppState;
32
+ ui: VoiceSpectrogramUI;
33
+ }
34
+
35
+ const presets = {
36
+ warm: { fundamental: 118, formants: [650, 1080, 2450], duration: 2.8, sampleRate: 16000 },
37
+ bright: { fundamental: 205, formants: [340, 2650, 3380], duration: 2.8, sampleRate: 16000 }
38
+ } satisfies Record<string, SyntheticVowel>;
39
+
40
+ function requireElement<T extends Element>(root: ParentNode, selector: string): T {
41
+ const element = root.querySelector<T>(selector);
42
+ if (!element) throw new Error(`Missing ${selector}`);
43
+ return element;
44
+ }
45
+
46
+ function parseUI(root: HTMLElement): VoiceSpectrogramUI {
47
+ const script = requireElement<HTMLScriptElement>(root, '[data-voice-content]');
48
+ return JSON.parse(script.textContent ?? '{}') as VoiceSpectrogramUI;
49
+ }
50
+
51
+ function presetTrack(key: keyof typeof presets, name: string, ceilingHz: number): Track {
52
+ const config = presets[key];
53
+ const samples = createSyntheticVowel(config);
54
+ return { samples, sampleRate: config.sampleRate, name, state: 'ready', analysis: analyzeSamples(samples, config.sampleRate, ceilingHz), error: '' };
55
+ }
56
+
57
+ function initialState(ui: VoiceSpectrogramUI): AppState {
58
+ const settings = loadSettings();
59
+ return {
60
+ settings,
61
+ tracks: {
62
+ a: presetTrack('warm', ui.presetWarmLabel, settings.ceilingHz),
63
+ b: presetTrack('bright', ui.presetBrightLabel, settings.ceilingHz)
64
+ },
65
+ active: null,
66
+ progress: 0
67
+ };
68
+ }
69
+
70
+ function stateLabel(track: Track, ui: VoiceSpectrogramUI): string {
71
+ const labels: Record<SampleState, string> = {
72
+ empty: ui.emptySampleLabel,
73
+ decoding: ui.decodingSampleLabel,
74
+ ready: ui.readySampleLabel,
75
+ error: ui.errorSampleLabel
76
+ };
77
+ return track.error || labels[track.state];
78
+ }
79
+
80
+ function setText(root: HTMLElement, selector: string, text: string): void {
81
+ const element = root.querySelector<HTMLElement>(selector);
82
+ if (element) element.textContent = text;
83
+ }
84
+
85
+ function renderTrack(root: HTMLElement, key: TrackKey, track: Track, ui: VoiceSpectrogramUI): void {
86
+ setText(root, `[data-name="${key}"]`, track.name);
87
+ setText(root, `[data-status="${key}"]`, stateLabel(track, ui));
88
+ setText(root, `[data-duration="${key}"]`, `${ui.durationLabel} ${formatDuration(track.analysis.duration)}`);
89
+ const card = root.querySelector<HTMLElement>(`[data-card="${key}"]`);
90
+ if (card) card.dataset.state = track.state;
91
+ track.analysis.averages?.forEach((value, index) => setText(root, `[data-value="${key}-${index}"]`, `${value} Hz`));
92
+ }
93
+
94
+ function renderComparison(root: HTMLElement, state: AppState, ui: VoiceSpectrogramUI): void {
95
+ const delta = compareFormants(state.tracks.a.analysis.averages, state.tracks.b.analysis.averages);
96
+ for (let index = 0; index < 3; index += 1) {
97
+ setText(root, `[data-value="d-${index}"]`, delta ? `${delta[index]} Hz` : ui.unavailableLabel);
98
+ }
99
+ }
100
+
101
+ function stageStatusLabel(state: AppState, ui: VoiceSpectrogramUI): string {
102
+ const evaluation = evaluateStage(state.tracks.a.state, state.tracks.b.state);
103
+ if (evaluation.key === 'ready') return ui.statusReadyLabel;
104
+ if (evaluation.key === 'single') return ui.statusSingleLabel;
105
+ return ui.statusEmptyLabel;
106
+ }
107
+
108
+ function renderControls(root: HTMLElement, state: AppState, ui: VoiceSpectrogramUI): void {
109
+ root.querySelectorAll<HTMLElement>('[data-ceiling]').forEach((button) => button.classList.toggle('is-active', Number(button.dataset.ceiling) === state.settings.ceilingHz));
110
+ root.querySelectorAll<HTMLElement>('[data-view]').forEach((button) => button.classList.toggle('is-active', button.dataset.view === state.settings.view));
111
+ setText(root, '[data-stage-status]', stageStatusLabel(state, ui));
112
+ }
113
+
114
+ function render(root: HTMLElement, canvas: HTMLCanvasElement, state: AppState, ui: VoiceSpectrogramUI): void {
115
+ renderTrack(root, 'a', state.tracks.a, ui);
116
+ renderTrack(root, 'b', state.tracks.b, ui);
117
+ renderComparison(root, state, ui);
118
+ renderControls(root, state, ui);
119
+ renderSpectrogram(root, canvas, {
120
+ first: state.tracks.a.analysis,
121
+ second: state.tracks.b.analysis,
122
+ active: state.active,
123
+ progress: state.progress,
124
+ view: state.settings.view
125
+ }, ui);
126
+ }
127
+
128
+ function errorMessage(error: unknown, ui: VoiceSpectrogramUI): string {
129
+ if (error instanceof Error && error.message === 'limit') return ui.limitError;
130
+ if (error instanceof Error && error.message === 'browser') return ui.browserError;
131
+ return ui.decodeError;
132
+ }
133
+
134
+ async function loadFile(context: ViewContext, key: TrackKey, file: File): Promise<void> {
135
+ context.state.tracks[key].state = 'decoding';
136
+ context.state.tracks[key].error = '';
137
+ render(context.root, context.canvas, context.state, context.ui);
138
+ try {
139
+ const audio = await decodeAudioFile(file);
140
+ context.state.tracks[key] = { ...audio, name: file.name, state: 'ready', analysis: analyzeSamples(audio.samples, audio.sampleRate, context.state.settings.ceilingHz), error: '' };
141
+ } catch (error) {
142
+ context.state.tracks[key].state = 'ready';
143
+ context.state.tracks[key].error = errorMessage(error, context.ui);
144
+ }
145
+ render(context.root, context.canvas, context.state, context.ui);
146
+ }
147
+
148
+ function bindFiles(root: HTMLElement, canvas: HTMLCanvasElement, state: AppState, ui: VoiceSpectrogramUI): void {
149
+ const context = { root, canvas, state, ui };
150
+ root.querySelectorAll<HTMLInputElement>('[data-file]').forEach((input) => input.addEventListener('change', () => {
151
+ const file = input.files?.[0];
152
+ if (file) void loadFile(context, input.dataset.file as TrackKey, file);
153
+ }));
154
+ root.querySelectorAll<HTMLElement>('[data-drop]').forEach((zone) => {
155
+ zone.addEventListener('dragover', (event) => event.preventDefault());
156
+ zone.addEventListener('drop', (event) => {
157
+ event.preventDefault();
158
+ const file = event.dataTransfer?.files[0];
159
+ if (file) void loadFile(context, zone.dataset.drop as TrackKey, file);
160
+ });
161
+ });
162
+ }
163
+
164
+ function bindPresets(root: HTMLElement, canvas: HTMLCanvasElement, state: AppState, ui: VoiceSpectrogramUI): void {
165
+ root.querySelectorAll<HTMLButtonElement>('[data-preset]').forEach((button) => button.addEventListener('click', () => {
166
+ const key = button.dataset.track as TrackKey;
167
+ const preset = button.dataset.preset as keyof typeof presets;
168
+ const name = preset === 'warm' ? ui.presetWarmLabel : ui.presetBrightLabel;
169
+ state.tracks[key] = presetTrack(preset, name, state.settings.ceilingHz);
170
+ render(root, canvas, state, ui);
171
+ }));
172
+ }
173
+
174
+ function bindSettings(root: HTMLElement, canvas: HTMLCanvasElement, state: AppState, ui: VoiceSpectrogramUI): void {
175
+ root.querySelectorAll<HTMLButtonElement>('[data-ceiling]').forEach((button) => button.addEventListener('click', () => {
176
+ state.settings.ceilingHz = Number(button.dataset.ceiling);
177
+ Object.values(state.tracks).forEach((track) => track.analysis = analyzeSamples(track.samples, track.sampleRate, state.settings.ceilingHz));
178
+ saveSettings(state.settings);
179
+ render(root, canvas, state, ui);
180
+ }));
181
+ root.querySelectorAll<HTMLButtonElement>('[data-view]').forEach((button) => button.addEventListener('click', () => {
182
+ state.settings.view = button.dataset.view as SpectrogramSettings['view'];
183
+ saveSettings(state.settings);
184
+ render(root, canvas, state, ui);
185
+ }));
186
+ }
187
+
188
+ function bindPlayback(root: HTMLElement, canvas: HTMLCanvasElement, state: AppState, ui: VoiceSpectrogramUI): void {
189
+ const player = new AudioPlayer();
190
+ root.querySelectorAll<HTMLButtonElement>('[data-play]').forEach((button) => button.addEventListener('click', () => {
191
+ const key = button.dataset.play as TrackKey;
192
+ state.active = key;
193
+ state.progress = 0;
194
+ void player.play(state.tracks[key], {
195
+ progress: (progress) => { state.progress = progress; render(root, canvas, state, ui); },
196
+ ended: () => { state.active = null; state.progress = 0; render(root, canvas, state, ui); }
197
+ });
198
+ }));
199
+ requireElement<HTMLButtonElement>(root, '[data-stop]').addEventListener('click', () => {
200
+ player.stop();
201
+ state.active = null;
202
+ state.progress = 0;
203
+ render(root, canvas, state, ui);
204
+ });
205
+ }
206
+
207
+ export function mountVoiceSpectrogram(root: HTMLElement): void {
208
+ if (root.dataset.mounted === 'true') return;
209
+ root.dataset.mounted = 'true';
210
+ const ui = parseUI(root);
211
+ const canvas = requireElement<HTMLCanvasElement>(root, 'canvas');
212
+ const state = initialState(ui);
213
+ bindFiles(root, canvas, state, ui);
214
+ bindPresets(root, canvas, state, ui);
215
+ bindSettings(root, canvas, state, ui);
216
+ bindPlayback(root, canvas, state, ui);
217
+ new ResizeObserver(() => render(root, canvas, state, ui)).observe(canvas);
218
+ render(root, canvas, state, ui);
219
+ }
@@ -0,0 +1,204 @@
1
+ import type { SpectrogramAnalysis } from './logic';
2
+ import type { SpectrogramView } from './storage';
3
+ import type { VoiceSpectrogramUI } from './ui';
4
+
5
+ export interface SpectrogramScene {
6
+ first: SpectrogramAnalysis | null;
7
+ second: SpectrogramAnalysis | null;
8
+ active: 'a' | 'b' | null;
9
+ progress: number;
10
+ view: SpectrogramView;
11
+ }
12
+
13
+ interface Palette {
14
+ background: string;
15
+ grid: string;
16
+ text: string;
17
+ first: [number, number, number];
18
+ second: [number, number, number];
19
+ firstLine: string;
20
+ secondLine: string;
21
+ cursor: string;
22
+ }
23
+
24
+ interface CanvasLayout {
25
+ width: number;
26
+ height: number;
27
+ }
28
+
29
+ interface FormantStyle {
30
+ color: string;
31
+ flipped: boolean;
32
+ }
33
+
34
+ const plateCache = new WeakMap<SpectrogramAnalysis, { color: string; plate: HTMLCanvasElement }>();
35
+
36
+ function parseHex(value: string): [number, number, number] {
37
+ const normalized = value.trim().replace('#', '');
38
+ const full = normalized.length === 3 ? normalized.split('').map((part) => part + part).join('') : normalized;
39
+ return [0, 2, 4].map((offset) => Number.parseInt(full.slice(offset, offset + 2), 16)) as [number, number, number];
40
+ }
41
+
42
+ function readPalette(root: HTMLElement): Palette {
43
+ const style = getComputedStyle(root);
44
+ const value = (name: string): string => style.getPropertyValue(name).trim();
45
+ return {
46
+ background: value('--n-va-canvas'),
47
+ grid: value('--n-va-grid'),
48
+ text: value('--n-va-muted'),
49
+ first: parseHex(value('--n-va-a')),
50
+ second: parseHex(value('--n-va-b')),
51
+ firstLine: value('--n-va-a-line'),
52
+ secondLine: value('--n-va-b-line'),
53
+ cursor: value('--n-va-cursor')
54
+ };
55
+ }
56
+
57
+ function buildPlate(analysis: SpectrogramAnalysis, color: [number, number, number]): HTMLCanvasElement {
58
+ const plate = document.createElement('canvas');
59
+ plate.width = analysis.frames.length;
60
+ plate.height = analysis.frames[0]?.energy.length ?? 1;
61
+ const context = plate.getContext('2d');
62
+ if (!context) return plate;
63
+ const image = context.createImageData(plate.width, plate.height);
64
+ analysis.frames.forEach((frame, x) => frame.energy.forEach((energy, bin) => {
65
+ const row = plate.height - 1 - bin;
66
+ const offset = ((row * plate.width) + x) * 4;
67
+ image.data[offset] = color[0];
68
+ image.data[offset + 1] = color[1];
69
+ image.data[offset + 2] = color[2];
70
+ image.data[offset + 3] = Math.round(255 * Math.pow(energy, 1.25));
71
+ }));
72
+ context.putImageData(image, 0, 0);
73
+ return plate;
74
+ }
75
+
76
+ function cachedPlate(analysis: SpectrogramAnalysis, color: [number, number, number]): HTMLCanvasElement {
77
+ const colorKey = color.join(',');
78
+ const cached = plateCache.get(analysis);
79
+ if (cached?.color === colorKey) return cached.plate;
80
+ const plate = buildPlate(analysis, color);
81
+ plateCache.set(analysis, { color: colorKey, plate });
82
+ return plate;
83
+ }
84
+
85
+ function drawPlate(context: CanvasRenderingContext2D, plate: HTMLCanvasElement, rect: DOMRect, flipped: boolean): void {
86
+ context.save();
87
+ if (flipped) {
88
+ context.translate(0, (2 * rect.y) + rect.height);
89
+ context.scale(1, -1);
90
+ }
91
+ context.drawImage(plate, rect.x, rect.y, rect.width, rect.height);
92
+ context.restore();
93
+ }
94
+
95
+ function frequencyY(frequency: number, analysis: SpectrogramAnalysis, rect: DOMRect, flipped: boolean): number {
96
+ const ratio = Math.min(1, frequency / analysis.ceilingHz);
97
+ return flipped ? rect.y + (ratio * rect.height) : rect.y + rect.height - (ratio * rect.height);
98
+ }
99
+
100
+ function drawFormants(context: CanvasRenderingContext2D, analysis: SpectrogramAnalysis, rect: DOMRect, style: FormantStyle): void {
101
+ context.save();
102
+ context.strokeStyle = style.color;
103
+ context.lineWidth = 1.5;
104
+ context.globalAlpha = 0.86;
105
+ for (let formant = 0; formant < 3; formant += 1) {
106
+ context.beginPath();
107
+ let drawing = false;
108
+ analysis.frames.forEach((frame, index) => {
109
+ const frequency = frame.formants?.[formant];
110
+ if (!frequency) {
111
+ drawing = false;
112
+ return;
113
+ }
114
+ const x = rect.x + ((index / Math.max(1, analysis.frames.length - 1)) * rect.width);
115
+ const y = frequencyY(frequency, analysis, rect, style.flipped);
116
+ if (drawing) context.lineTo(x, y);
117
+ else context.moveTo(x, y);
118
+ drawing = true;
119
+ });
120
+ context.stroke();
121
+ }
122
+ context.restore();
123
+ }
124
+
125
+ function drawGrid(context: CanvasRenderingContext2D, width: number, height: number, palette: Palette): void {
126
+ context.strokeStyle = palette.grid;
127
+ context.lineWidth = 1;
128
+ for (let index = 1; index < 8; index += 1) {
129
+ const x = 58 + (((width - 82) / 8) * index);
130
+ context.beginPath();
131
+ context.moveTo(x, 24);
132
+ context.lineTo(x, height - 30);
133
+ context.stroke();
134
+ }
135
+ context.beginPath();
136
+ context.moveTo(58, height / 2);
137
+ context.lineTo(width - 24, height / 2);
138
+ context.stroke();
139
+ }
140
+
141
+ function plateRects(width: number, height: number): [DOMRect, DOMRect] {
142
+ const x = 58;
143
+ const plotWidth = width - 82;
144
+ const halfHeight = (height - 54) / 2;
145
+ return [new DOMRect(x, 24, plotWidth, halfHeight), new DOMRect(x, 24 + halfHeight, plotWidth, halfHeight)];
146
+ }
147
+
148
+ function drawLabels(context: CanvasRenderingContext2D, layout: CanvasLayout, ui: VoiceSpectrogramUI, palette: Palette): void {
149
+ context.fillStyle = palette.text;
150
+ context.font = '12px sans-serif';
151
+ context.fillText(ui.sampleALabel, 12, 42);
152
+ context.fillText(ui.sampleBLabel, 12, layout.height - 38);
153
+ context.textAlign = 'center';
154
+ context.fillText(ui.timeAxisLabel, layout.width / 2, layout.height - 8);
155
+ context.save();
156
+ context.translate(14, layout.height / 2);
157
+ context.rotate(-Math.PI / 2);
158
+ context.fillText(ui.frequencyAxisLabel, 0, 0);
159
+ context.restore();
160
+ context.textAlign = 'left';
161
+ }
162
+
163
+ function drawCursor(context: CanvasRenderingContext2D, scene: SpectrogramScene, layout: CanvasLayout, palette: Palette): void {
164
+ if (!scene.active) return;
165
+ const x = 58 + ((layout.width - 82) * scene.progress);
166
+ context.strokeStyle = palette.cursor;
167
+ context.lineWidth = 2;
168
+ context.beginPath();
169
+ context.moveTo(x, 20);
170
+ context.lineTo(x, layout.height - 28);
171
+ context.stroke();
172
+ context.fillStyle = palette.cursor;
173
+ context.beginPath();
174
+ context.arc(x, layout.height / 2, 4, 0, Math.PI * 2);
175
+ context.fill();
176
+ }
177
+
178
+ export function renderSpectrogram(root: HTMLElement, canvas: HTMLCanvasElement, scene: SpectrogramScene, ui: VoiceSpectrogramUI): void {
179
+ const ratio = Math.min(2, window.devicePixelRatio || 1);
180
+ const width = Math.max(320, canvas.clientWidth);
181
+ const height = Math.max(360, canvas.clientHeight);
182
+ const layout = { width, height };
183
+ canvas.width = Math.round(width * ratio);
184
+ canvas.height = Math.round(height * ratio);
185
+ const context = canvas.getContext('2d');
186
+ if (!context) return;
187
+ context.scale(ratio, ratio);
188
+ const palette = readPalette(root);
189
+ context.fillStyle = palette.background;
190
+ context.fillRect(0, 0, width, height);
191
+ drawGrid(context, width, height, palette);
192
+ const [firstRect, secondRect] = plateRects(width, height);
193
+ const secondFlipped = scene.view === 'mirror';
194
+ if (scene.first) {
195
+ drawPlate(context, cachedPlate(scene.first, palette.first), firstRect, false);
196
+ drawFormants(context, scene.first, firstRect, { color: palette.firstLine, flipped: false });
197
+ }
198
+ if (scene.second) {
199
+ drawPlate(context, cachedPlate(scene.second, palette.second), secondRect, secondFlipped);
200
+ drawFormants(context, scene.second, secondRect, { color: palette.secondLine, flipped: secondFlipped });
201
+ }
202
+ drawLabels(context, layout, ui, palette);
203
+ drawCursor(context, scene, layout, palette);
204
+ }
@@ -0,0 +1,31 @@
1
+ import type { ScienceToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { VoiceSpectrogramUI } from './ui';
3
+
4
+ export type { VoiceSpectrogramUI } from './ui';
5
+
6
+ export type VoiceSpectrogramLocaleContent = ToolLocaleContent<VoiceSpectrogramUI>;
7
+
8
+ export const voiceSpectrogramAnalyzer: ScienceToolEntry<VoiceSpectrogramUI> = {
9
+ id: 'voice-spectrogram-analyzer',
10
+ icons: {
11
+ bg: 'mdi:waveform',
12
+ fg: 'mdi:microphone-outline'
13
+ },
14
+ i18n: {
15
+ de: () => import('./i18n/de').then((module) => module.content),
16
+ en: () => import('./i18n/en').then((module) => module.content),
17
+ es: () => import('./i18n/es').then((module) => module.content),
18
+ fr: () => import('./i18n/fr').then((module) => module.content),
19
+ id: () => import('./i18n/id').then((module) => module.content),
20
+ it: () => import('./i18n/it').then((module) => module.content),
21
+ ja: () => import('./i18n/ja').then((module) => module.content),
22
+ ko: () => import('./i18n/ko').then((module) => module.content),
23
+ nl: () => import('./i18n/nl').then((module) => module.content),
24
+ pl: () => import('./i18n/pl').then((module) => module.content),
25
+ pt: () => import('./i18n/pt').then((module) => module.content),
26
+ ru: () => import('./i18n/ru').then((module) => module.content),
27
+ sv: () => import('./i18n/sv').then((module) => module.content),
28
+ tr: () => import('./i18n/tr').then((module) => module.content),
29
+ zh: () => import('./i18n/zh').then((module) => module.content)
30
+ }
31
+ };
@@ -0,0 +1,13 @@
1
+ export type SampleState = 'empty' | 'decoding' | 'ready' | 'error';
2
+
3
+ export interface StageEvaluation {
4
+ key: 'empty' | 'single' | 'ready';
5
+ loadedCount: number;
6
+ }
7
+
8
+ export function evaluateStage(first: SampleState, second: SampleState): StageEvaluation {
9
+ const loadedCount = [first, second].filter((state) => state === 'ready').length;
10
+ if (loadedCount === 0) return { key: 'empty', loadedCount };
11
+ if (loadedCount === 1) return { key: 'single', loadedCount };
12
+ return { key: 'ready', loadedCount };
13
+ }
@@ -0,0 +1,64 @@
1
+ export function createHammingWindow(size: number): Float32Array {
2
+ const window = new Float32Array(size);
3
+ const denominator = Math.max(1, size - 1);
4
+ for (let index = 0; index < size; index += 1) {
5
+ window[index] = 0.54 - (0.46 * Math.cos((2 * Math.PI * index) / denominator));
6
+ }
7
+ return window;
8
+ }
9
+
10
+ function reverseBits(value: number, bits: number): number {
11
+ let reversed = 0;
12
+ let current = value;
13
+ for (let index = 0; index < bits; index += 1) {
14
+ reversed = (reversed << 1) | (current & 1);
15
+ current >>= 1;
16
+ }
17
+ return reversed;
18
+ }
19
+
20
+ function reorder(real: Float64Array, imaginary: Float64Array): void {
21
+ const bits = Math.log2(real.length);
22
+ for (let index = 0; index < real.length; index += 1) {
23
+ const target = reverseBits(index, bits);
24
+ if (target <= index) continue;
25
+ [real[index], real[target]] = [real[target]!, real[index]!];
26
+ [imaginary[index], imaginary[target]] = [imaginary[target]!, imaginary[index]!];
27
+ }
28
+ }
29
+
30
+ function transformStage(real: Float64Array, imaginary: Float64Array, size: number): void {
31
+ const half = size / 2;
32
+ const angleStep = (-2 * Math.PI) / size;
33
+ for (let start = 0; start < real.length; start += size) {
34
+ for (let offset = 0; offset < half; offset += 1) {
35
+ const angle = angleStep * offset;
36
+ const cosine = Math.cos(angle);
37
+ const sine = Math.sin(angle);
38
+ const upper = start + offset;
39
+ const lower = upper + half;
40
+ const upperReal = real[upper]!;
41
+ const upperImaginary = imaginary[upper]!;
42
+ const lowerReal = real[lower]!;
43
+ const lowerImaginary = imaginary[lower]!;
44
+ const realPart = (lowerReal * cosine) - (lowerImaginary * sine);
45
+ const imaginaryPart = (lowerReal * sine) + (lowerImaginary * cosine);
46
+ real[lower] = upperReal - realPart;
47
+ imaginary[lower] = upperImaginary - imaginaryPart;
48
+ real[upper] = upperReal + realPart;
49
+ imaginary[upper] = upperImaginary + imaginaryPart;
50
+ }
51
+ }
52
+ }
53
+
54
+ export function fftMagnitudes(input: Float32Array): Float64Array {
55
+ const real = Float64Array.from(input);
56
+ const imaginary = new Float64Array(input.length);
57
+ reorder(real, imaginary);
58
+ for (let size = 2; size <= input.length; size *= 2) transformStage(real, imaginary, size);
59
+ const magnitudes = new Float64Array(input.length / 2);
60
+ for (let index = 0; index < magnitudes.length; index += 1) {
61
+ magnitudes[index] = Math.hypot(real[index]!, imaginary[index]!) / input.length;
62
+ }
63
+ return magnitudes;
64
+ }