@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.
- package/package.json +2 -1
- package/src/category/index.ts +63 -59
- package/src/category/seo.astro +1 -1
- package/src/components/PreviewNavSidebar.astro +1 -1
- package/src/components/PreviewToolbar.astro +1 -1
- package/src/data.ts +1 -1
- package/src/entries.ts +74 -68
- package/src/env.d.ts +1 -1
- package/src/index.ts +33 -31
- package/src/pages/[locale]/[slug].astro +161 -159
- package/src/pages/[locale].astro +8 -5
- package/src/pages/index.astro +1 -1
- package/src/tests/bibliography_wellformed_export.test.ts +46 -0
- package/src/tests/diacritics_density.test.ts +1 -1
- package/src/tests/faq_count.test.ts +19 -19
- package/src/tests/i18n_coverage.test.ts +36 -36
- package/src/tests/inverted_punctuation.test.ts +1 -1
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/mocks/astro_mock.js +1 -1
- package/src/tests/no_h1_in_components.test.ts +1 -1
- package/src/tests/script_density.test.ts +94 -94
- package/src/tests/seo_length.test.ts +46 -46
- package/src/tests/seo_parity.test.ts +2 -7
- package/src/tests/seo_translation_completeness.test.ts +68 -0
- package/src/tests/seo_wellformed_export.test.ts +65 -0
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tests/translation_copy.test.ts +23 -24
- package/src/tool/bloodstain-pattern-origin-analyzer/component.astro +9 -9
- package/src/tool/bloodstain-pattern-origin-analyzer/i18n/zh.ts +1 -1
- package/src/tool/dna-profile-match-probability-lab/bibliography.astro +14 -0
- package/src/tool/dna-profile-match-probability-lab/bibliography.ts +16 -0
- package/src/tool/dna-profile-match-probability-lab/component.astro +121 -0
- package/src/tool/dna-profile-match-probability-lab/controller.ts +159 -0
- package/src/tool/dna-profile-match-probability-lab/dna-profile-match-probability-lab.css +485 -0
- package/src/tool/dna-profile-match-probability-lab/dom-views.ts +93 -0
- package/src/tool/dna-profile-match-probability-lab/entry.ts +29 -0
- package/src/tool/dna-profile-match-probability-lab/evaluator.ts +19 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/de.ts +43 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/en.ts +196 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/es.ts +40 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/fr.ts +10 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/id.ts +41 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/it.ts +10 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/ja.ts +40 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/ko.ts +40 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/nl.ts +29 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/pl.ts +10 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/pt.ts +41 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/ru.ts +29 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/sv.ts +41 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/tr.ts +10 -0
- package/src/tool/dna-profile-match-probability-lab/i18n/zh.ts +29 -0
- package/src/tool/dna-profile-match-probability-lab/index.ts +11 -0
- package/src/tool/dna-profile-match-probability-lab/localize.ts +107 -0
- package/src/tool/dna-profile-match-probability-lab/logic.test.ts +56 -0
- package/src/tool/dna-profile-match-probability-lab/logic.ts +139 -0
- package/src/tool/dna-profile-match-probability-lab/seo.astro +15 -0
- package/src/tool/dna-profile-match-probability-lab/storage.ts +26 -0
- package/src/tool/dna-profile-match-probability-lab/ui.ts +3 -0
- package/src/tool/fire-pattern-origin-analyzer/component.astro +1 -1
- package/src/tool/fire-pattern-origin-analyzer/i18n/zh.ts +5 -1
- package/src/tool/fire-pattern-origin-analyzer/logic.ts +9 -2
- package/src/tool/fire-pattern-origin-analyzer/view-bindings.ts +8 -4
- package/src/tool/fire-pattern-origin-analyzer/view-model.ts +6 -2
- package/src/tool/forensic-age-estimator/bibliography.astro +1 -1
- package/src/tool/forensic-age-estimator/component.astro +1 -1
- package/src/tool/forensic-age-estimator/entry.ts +17 -17
- package/src/tool/forensic-age-estimator/i18n/de.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/en.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/es.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/fr.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/id.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/it.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/nl.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/pl.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/pt.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/ru.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/sv.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/tr.ts +244 -244
- package/src/tool/forensic-age-estimator/i18n/zh.ts +245 -245
- package/src/tool/forensic-age-estimator/index.ts +1 -1
- package/src/tool/forensic-age-estimator/seo.astro +1 -1
- package/src/tool/forensic-blood-test-simulator/component.astro +1 -1
- package/src/tool/forensic-fiber-comparison-microscope/component.astro +1 -1
- package/src/tool/forensic-fiber-comparison-microscope/render.ts +1 -1
- package/src/tool/forensic-fiber-comparison-microscope/view.ts +1 -1
- package/src/tool/forensic-fingerprint-minutiae-identifier/component.astro +2 -2
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/ja.ts +2 -1
- package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/zh.ts +2 -0
- package/src/tool/forensic-fingerprint-minutiae-identifier/renderer.ts +9 -8
- package/src/tool/forensic-glass-becke-line-simulator/component.astro +1 -1
- package/src/tool/forensic-glass-becke-line-simulator/view.ts +8 -6
- package/src/tool/forensic-image-authenticity-analyzer/component.astro +1 -1
- package/src/tool/forensic-image-authenticity-analyzer/i18n/de.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/en.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/es.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/fr.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/id.ts +95 -90
- package/src/tool/forensic-image-authenticity-analyzer/i18n/it.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/ja.ts +7 -2
- package/src/tool/forensic-image-authenticity-analyzer/i18n/ko.ts +4 -1
- package/src/tool/forensic-image-authenticity-analyzer/i18n/nl.ts +95 -92
- package/src/tool/forensic-image-authenticity-analyzer/i18n/pl.ts +95 -92
- package/src/tool/forensic-image-authenticity-analyzer/i18n/pt.ts +105 -105
- package/src/tool/forensic-image-authenticity-analyzer/i18n/ru.ts +97 -92
- package/src/tool/forensic-image-authenticity-analyzer/i18n/sv.ts +97 -93
- package/src/tool/forensic-image-authenticity-analyzer/i18n/tr.ts +98 -93
- package/src/tool/forensic-image-authenticity-analyzer/i18n/zh.ts +11 -0
- package/src/tool/forensic-microcrystal-drug-simulator/component.astro +1 -1
- package/src/tool/forensic-sex-determinator/component.astro +3 -2
- package/src/tool/forensic-stature-estimator/component.astro +1 -1
- package/src/tool/forensic-stature-estimator/i18n/id.ts +4 -0
- package/src/tool/forensic-stature-estimator/i18n/nl.ts +4 -0
- package/src/tool/forensic-stature-estimator/i18n/ru.ts +4 -0
- package/src/tool/forensic-tlc-ink-simulator/component.astro +1 -1
- package/src/tool/forensic-tlc-ink-simulator/i18n/zh.ts +1 -0
- package/src/tool/forensic-toolmark-striation-matcher/component.astro +1 -1
- package/src/tool/forensic-toolmark-striation-matcher/logic.ts +1 -1
- package/src/tool/forensic-toolmark-striation-matcher/renderer.ts +7 -6
- package/src/tool/forensic-toolmark-striation-matcher/view.ts +19 -20
- package/src/tool/gsr-dispersion-calculator/component.astro +1 -1
- package/src/tool/gsr-dispersion-calculator/logic.ts +1 -1
- package/src/tool/time-of-death-algor-mortis-calculator/entry.ts +2 -0
- package/src/tool/voice-spectrogram-analyzer/audio-runtime.ts +75 -0
- package/src/tool/voice-spectrogram-analyzer/bibliography.astro +14 -0
- package/src/tool/voice-spectrogram-analyzer/bibliography.ts +16 -0
- package/src/tool/voice-spectrogram-analyzer/component.astro +116 -0
- package/src/tool/voice-spectrogram-analyzer/controller.ts +219 -0
- package/src/tool/voice-spectrogram-analyzer/dom-views.ts +204 -0
- package/src/tool/voice-spectrogram-analyzer/entry.ts +31 -0
- package/src/tool/voice-spectrogram-analyzer/evaluator.ts +13 -0
- package/src/tool/voice-spectrogram-analyzer/fft.ts +64 -0
- package/src/tool/voice-spectrogram-analyzer/i18n/de.ts +135 -0
- package/src/tool/voice-spectrogram-analyzer/i18n/en.ts +122 -0
- package/src/tool/voice-spectrogram-analyzer/i18n/es.ts +122 -0
- package/src/tool/voice-spectrogram-analyzer/i18n/fr.ts +135 -0
- package/src/tool/voice-spectrogram-analyzer/i18n/id.ts +136 -0
- package/src/tool/voice-spectrogram-analyzer/i18n/it.ts +135 -0
- package/src/tool/voice-spectrogram-analyzer/i18n/ja.ts +135 -0
- package/src/tool/voice-spectrogram-analyzer/i18n/ko.ts +135 -0
- package/src/tool/voice-spectrogram-analyzer/i18n/nl.ts +136 -0
- package/src/tool/voice-spectrogram-analyzer/i18n/pl.ts +136 -0
- package/src/tool/voice-spectrogram-analyzer/i18n/pt.ts +135 -0
- package/src/tool/voice-spectrogram-analyzer/i18n/ru.ts +136 -0
- package/src/tool/voice-spectrogram-analyzer/i18n/sv.ts +136 -0
- package/src/tool/voice-spectrogram-analyzer/i18n/tr.ts +136 -0
- package/src/tool/voice-spectrogram-analyzer/i18n/zh.ts +135 -0
- package/src/tool/voice-spectrogram-analyzer/index.ts +11 -0
- package/src/tool/voice-spectrogram-analyzer/logic.test.ts +46 -0
- package/src/tool/voice-spectrogram-analyzer/logic.ts +163 -0
- package/src/tool/voice-spectrogram-analyzer/seo.astro +15 -0
- package/src/tool/voice-spectrogram-analyzer/storage.ts +33 -0
- package/src/tool/voice-spectrogram-analyzer/ui.ts +49 -0
- package/src/tool/voice-spectrogram-analyzer/voice-spectrogram-analyzer.css +547 -0
- package/src/tool/widmark-alcohol-simulator/component.astro +1 -1
- package/src/tool/widmark-alcohol-simulator/i18n/de.ts +5 -0
- package/src/tool/widmark-alcohol-simulator/i18n/fr.ts +2 -1
- package/src/tool/widmark-alcohol-simulator/i18n/id.ts +1 -0
- package/src/tool/widmark-alcohol-simulator/i18n/it.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/ja.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/nl.ts +5 -1
- package/src/tool/widmark-alcohol-simulator/i18n/pl.ts +5 -1
- package/src/tool/widmark-alcohol-simulator/i18n/pt.ts +5 -1
- package/src/tool/widmark-alcohol-simulator/i18n/ru.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/sv.ts +5 -1
- package/src/tool/widmark-alcohol-simulator/i18n/tr.ts +4 -1
- package/src/tool/widmark-alcohol-simulator/i18n/zh.ts +4 -1
- package/src/tools.ts +39 -35
- 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
|
+
}
|