@jjlmoya/utils-forensic-science 1.12.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.
- package/package.json +1 -1
- package/src/category/index.ts +3 -1
- package/src/entries.ts +5 -1
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- 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/tools.ts +3 -1
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -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,
|
|
@@ -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
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
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
|
+
}
|