@jjlmoya/utils-forensic-science 1.11.0 → 1.13.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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +5 -1
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +2 -2
  6. package/src/tests/spanish_leakage.test.ts +25 -3
  7. package/src/tests/tool_validation.test.ts +2 -2
  8. package/src/tool/voice-spectrogram-analyzer/audio-runtime.ts +75 -0
  9. package/src/tool/voice-spectrogram-analyzer/bibliography.astro +14 -0
  10. package/src/tool/voice-spectrogram-analyzer/bibliography.ts +16 -0
  11. package/src/tool/voice-spectrogram-analyzer/component.astro +116 -0
  12. package/src/tool/voice-spectrogram-analyzer/controller.ts +219 -0
  13. package/src/tool/voice-spectrogram-analyzer/dom-views.ts +204 -0
  14. package/src/tool/voice-spectrogram-analyzer/entry.ts +31 -0
  15. package/src/tool/voice-spectrogram-analyzer/evaluator.ts +13 -0
  16. package/src/tool/voice-spectrogram-analyzer/fft.ts +64 -0
  17. package/src/tool/voice-spectrogram-analyzer/i18n/de.ts +135 -0
  18. package/src/tool/voice-spectrogram-analyzer/i18n/en.ts +122 -0
  19. package/src/tool/voice-spectrogram-analyzer/i18n/es.ts +122 -0
  20. package/src/tool/voice-spectrogram-analyzer/i18n/fr.ts +135 -0
  21. package/src/tool/voice-spectrogram-analyzer/i18n/id.ts +136 -0
  22. package/src/tool/voice-spectrogram-analyzer/i18n/it.ts +135 -0
  23. package/src/tool/voice-spectrogram-analyzer/i18n/ja.ts +135 -0
  24. package/src/tool/voice-spectrogram-analyzer/i18n/ko.ts +135 -0
  25. package/src/tool/voice-spectrogram-analyzer/i18n/nl.ts +136 -0
  26. package/src/tool/voice-spectrogram-analyzer/i18n/pl.ts +136 -0
  27. package/src/tool/voice-spectrogram-analyzer/i18n/pt.ts +135 -0
  28. package/src/tool/voice-spectrogram-analyzer/i18n/ru.ts +136 -0
  29. package/src/tool/voice-spectrogram-analyzer/i18n/sv.ts +136 -0
  30. package/src/tool/voice-spectrogram-analyzer/i18n/tr.ts +136 -0
  31. package/src/tool/voice-spectrogram-analyzer/i18n/zh.ts +135 -0
  32. package/src/tool/voice-spectrogram-analyzer/index.ts +11 -0
  33. package/src/tool/voice-spectrogram-analyzer/logic.test.ts +46 -0
  34. package/src/tool/voice-spectrogram-analyzer/logic.ts +163 -0
  35. package/src/tool/voice-spectrogram-analyzer/seo.astro +15 -0
  36. package/src/tool/voice-spectrogram-analyzer/storage.ts +33 -0
  37. package/src/tool/voice-spectrogram-analyzer/ui.ts +49 -0
  38. package/src/tool/voice-spectrogram-analyzer/voice-spectrogram-analyzer.css +547 -0
  39. package/src/tools.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-forensic-science",
3
- "version": "1.11.0",
3
+ "version": "1.13.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -15,6 +15,7 @@ import { forensicFingerprintMinutiaeIdentifier } from '../tool/forensic-fingerpr
15
15
  import { firePatternOriginAnalyzer } from '../tool/fire-pattern-origin-analyzer/entry';
16
16
  import { forensicToolmarkStriationMatcher } from '../tool/forensic-toolmark-striation-matcher/entry';
17
17
  import { timeOfDeathAlgorMortisCalculator } from '../tool/time-of-death-algor-mortis-calculator/entry';
18
+ import { voiceSpectrogramAnalyzer } from '../tool/voice-spectrogram-analyzer/entry';
18
19
 
19
20
  export const forensicCategory: ScienceCategoryEntry = {
20
21
  icon: 'mdi:fingerprint',
@@ -34,7 +35,8 @@ export const forensicCategory: ScienceCategoryEntry = {
34
35
  forensicFingerprintMinutiaeIdentifier,
35
36
  firePatternOriginAnalyzer,
36
37
  forensicToolmarkStriationMatcher,
37
- timeOfDeathAlgorMortisCalculator
38
+ timeOfDeathAlgorMortisCalculator,
39
+ voiceSpectrogramAnalyzer
38
40
  ],
39
41
 
40
42
 
package/src/entries.ts CHANGED
@@ -30,6 +30,8 @@ export { forensicToolmarkStriationMatcher } from './tool/forensic-toolmark-stria
30
30
  export type { ToolmarkStriationMatcherUI, ToolmarkStriationMatcherLocaleContent } from './tool/forensic-toolmark-striation-matcher/entry';
31
31
  export { timeOfDeathAlgorMortisCalculator } from './tool/time-of-death-algor-mortis-calculator/entry';
32
32
  export type { TimeOfDeathAlgorMortisUI, TimeOfDeathAlgorMortisLocaleContent } from './tool/time-of-death-algor-mortis-calculator/entry';
33
+ export { voiceSpectrogramAnalyzer } from './tool/voice-spectrogram-analyzer/entry';
34
+ export type { VoiceSpectrogramUI, VoiceSpectrogramLocaleContent } from './tool/voice-spectrogram-analyzer/entry';
33
35
 
34
36
  import { forensicAgeEstimator } from './tool/forensic-age-estimator/entry';
35
37
  import { widmarkAlcoholSimulator } from './tool/widmark-alcohol-simulator/entry';
@@ -47,6 +49,7 @@ import { forensicFingerprintMinutiaeIdentifier } from './tool/forensic-fingerpri
47
49
  import { firePatternOriginAnalyzer } from './tool/fire-pattern-origin-analyzer/entry';
48
50
  import { forensicToolmarkStriationMatcher } from './tool/forensic-toolmark-striation-matcher/entry';
49
51
  import { timeOfDeathAlgorMortisCalculator } from './tool/time-of-death-algor-mortis-calculator/entry';
52
+ import { voiceSpectrogramAnalyzer } from './tool/voice-spectrogram-analyzer/entry';
50
53
 
51
54
  export const ALL_ENTRIES = [
52
55
  forensicAgeEstimator,
@@ -64,7 +67,8 @@ export const ALL_ENTRIES = [
64
67
  forensicFingerprintMinutiaeIdentifier,
65
68
  firePatternOriginAnalyzer,
66
69
  forensicToolmarkStriationMatcher,
67
- timeOfDeathAlgorMortisCalculator
70
+ timeOfDeathAlgorMortisCalculator,
71
+ voiceSpectrogramAnalyzer
68
72
  ];
69
73
 
70
74
 
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export { BLOODSTAIN_PATTERN_ORIGIN_ANALYZER_TOOL } from './tool/bloodstain-patte
15
15
  export { FORENSIC_FINGERPRINT_MINUTIAE_IDENTIFIER_TOOL } from './tool/forensic-fingerprint-minutiae-identifier/index';
16
16
  export { FIRE_PATTERN_ORIGIN_ANALYZER_TOOL } from './tool/fire-pattern-origin-analyzer/index';
17
17
  export { FORENSIC_TOOLMARK_STRIATION_MATCHER_TOOL } from './tool/forensic-toolmark-striation-matcher/index';
18
+ export { VOICE_SPECTROGRAM_ANALYZER_TOOL } from './tool/voice-spectrogram-analyzer/index';
18
19
 
19
20
  export type {
20
21
  KnownLocale,
@@ -18,7 +18,7 @@ describe('Locale Completeness Validation', () => {
18
18
  });
19
19
  });
20
20
 
21
- it('all 16 tools registered', () => {
22
- expect(ALL_TOOLS.length).toBe(16);
21
+ it('all 17 tools registered', () => {
22
+ expect(ALL_TOOLS.length).toBe(17);
23
23
  });
24
24
  });
@@ -28,6 +28,8 @@ const TRANSLATABLE_KEYS = [
28
28
  'schemas',
29
29
  ] as const;
30
30
 
31
+ const COPY_THRESHOLD = 0.9;
32
+
31
33
  const SPANISH_MARKERS = [
32
34
  ['sangre', /\bsangre\b/gi],
33
35
  ['molino', /\bmolino\b/gi],
@@ -65,12 +67,16 @@ function normalize(value: string): string {
65
67
  }
66
68
 
67
69
  function isTechnicalInvariant(text: string): boolean {
70
+ const ledUnitMatches = text.match(/\b\d+(?:-\d+)?\s*(?:w|lm)\b/g) ?? [];
68
71
  return [
69
72
  'data:image/svg+xml;base64',
70
73
  'background-image: url',
71
74
  '.layout-playground {',
72
75
  'const samplerate =',
73
- ].some((pattern) => text.includes(pattern)) || (text.includes('presets') && text.includes('hz'));
76
+ ].some((pattern) => text.includes(pattern)) ||
77
+ (text.includes('presets') && text.includes('hz')) ||
78
+ (text.includes('t_rectal') && text.includes('exp(-k * t)')) ||
79
+ (text.includes('led') && ledUnitMatches.length >= 3);
74
80
  }
75
81
 
76
82
  function collectString(value: string, output: string[]): void {
@@ -111,10 +117,26 @@ function findSpanishMarkers(text: string[]): string[] {
111
117
  );
112
118
  }
113
119
 
120
+ function similarity(left: string, right: string): number {
121
+ const leftTokens = left.split(/\s+/);
122
+ const rightCounts = new Map<string, number>();
123
+ right.split(/\s+/).forEach((token) => rightCounts.set(token, (rightCounts.get(token) ?? 0) + 1));
124
+ const matches = leftTokens.reduce((total, token) => {
125
+ const count = rightCounts.get(token) ?? 0;
126
+ if (count > 0) rightCounts.set(token, count - 1);
127
+ return total + (count > 0 ? 1 : 0);
128
+ }, 0);
129
+ return (2 * matches) / (leftTokens.length + right.split(/\s+/).length);
130
+ }
131
+
114
132
  function findCopiedFragments(spanish: string[], translated: string[]): string[] {
115
- const corpus = translated.join(' ');
116
133
  return spanish
117
- .filter((fragment) => fragment.length >= 80 && corpus.includes(fragment))
134
+ .filter((fragment) => fragment.length >= 80)
135
+ .filter((fragment) => translated.some((candidate) =>
136
+ candidate.length >= 80 &&
137
+ Math.min(fragment.length, candidate.length) / Math.max(fragment.length, candidate.length) >= COPY_THRESHOLD &&
138
+ similarity(fragment, candidate) >= COPY_THRESHOLD,
139
+ ))
118
140
  .sort((a, b) => b.length - a.length)
119
141
  .slice(0, 3);
120
142
  }
@@ -4,8 +4,8 @@ import { scienceCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 16 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(16);
7
+ it('should have 17 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(17);
9
9
  });
10
10
 
11
11
  it('scienceCategory should be defined', () => {
@@ -0,0 +1,75 @@
1
+ const MAX_FILE_BYTES = 25 * 1024 * 1024;
2
+ const MAX_SECONDS = 20;
3
+
4
+ export interface DecodedAudio {
5
+ samples: Float32Array;
6
+ sampleRate: number;
7
+ }
8
+
9
+ export interface PlaybackCallbacks {
10
+ progress: (ratio: number) => void;
11
+ ended: () => void;
12
+ }
13
+
14
+ function getAudioContext(): AudioContext {
15
+ const Constructor = window.AudioContext;
16
+ if (!Constructor) throw new Error('browser');
17
+ return new Constructor();
18
+ }
19
+
20
+ function downmix(buffer: AudioBuffer): Float32Array {
21
+ const length = Math.min(buffer.length, Math.floor(buffer.sampleRate * MAX_SECONDS));
22
+ const output = new Float32Array(length);
23
+ for (let channel = 0; channel < buffer.numberOfChannels; channel += 1) {
24
+ const source = buffer.getChannelData(channel);
25
+ for (let index = 0; index < length; index += 1) output[index] = output[index]! + (source[index]! / buffer.numberOfChannels);
26
+ }
27
+ return output;
28
+ }
29
+
30
+ export async function decodeAudioFile(file: File): Promise<DecodedAudio> {
31
+ if (file.size > MAX_FILE_BYTES) throw new Error('limit');
32
+ const context = getAudioContext();
33
+ try {
34
+ const buffer = await context.decodeAudioData(await file.arrayBuffer());
35
+ return { samples: downmix(buffer), sampleRate: buffer.sampleRate };
36
+ } finally {
37
+ await context.close();
38
+ }
39
+ }
40
+
41
+ export class AudioPlayer {
42
+ private context: AudioContext | null = null;
43
+ private source: AudioBufferSourceNode | null = null;
44
+ private frame = 0;
45
+
46
+ stop(): void {
47
+ cancelAnimationFrame(this.frame);
48
+ try {
49
+ if (this.source) this.source.onended = null;
50
+ this.source?.stop();
51
+ } catch {}
52
+ this.source = null;
53
+ }
54
+
55
+ async play(audio: DecodedAudio, callbacks: PlaybackCallbacks): Promise<void> {
56
+ this.stop();
57
+ this.context ??= getAudioContext();
58
+ await this.context.resume();
59
+ const buffer = this.context.createBuffer(1, audio.samples.length, audio.sampleRate);
60
+ buffer.copyToChannel(new Float32Array(audio.samples), 0);
61
+ const source = this.context.createBufferSource();
62
+ source.buffer = buffer;
63
+ source.connect(this.context.destination);
64
+ const started = this.context.currentTime;
65
+ source.onended = callbacks.ended;
66
+ this.source = source;
67
+ source.start();
68
+ const tick = (): void => {
69
+ if (this.source !== source || !this.context) return;
70
+ callbacks.progress(Math.min(1, (this.context.currentTime - started) / buffer.duration));
71
+ this.frame = requestAnimationFrame(tick);
72
+ };
73
+ tick();
74
+ }
75
+ }
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { voiceSpectrogramAnalyzer } 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 voiceSpectrogramAnalyzer.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -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
+ }