@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.
Files changed (38) 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/tool_validation.test.ts +2 -2
  7. package/src/tool/voice-spectrogram-analyzer/audio-runtime.ts +75 -0
  8. package/src/tool/voice-spectrogram-analyzer/bibliography.astro +14 -0
  9. package/src/tool/voice-spectrogram-analyzer/bibliography.ts +16 -0
  10. package/src/tool/voice-spectrogram-analyzer/component.astro +116 -0
  11. package/src/tool/voice-spectrogram-analyzer/controller.ts +219 -0
  12. package/src/tool/voice-spectrogram-analyzer/dom-views.ts +204 -0
  13. package/src/tool/voice-spectrogram-analyzer/entry.ts +31 -0
  14. package/src/tool/voice-spectrogram-analyzer/evaluator.ts +13 -0
  15. package/src/tool/voice-spectrogram-analyzer/fft.ts +64 -0
  16. package/src/tool/voice-spectrogram-analyzer/i18n/de.ts +135 -0
  17. package/src/tool/voice-spectrogram-analyzer/i18n/en.ts +122 -0
  18. package/src/tool/voice-spectrogram-analyzer/i18n/es.ts +122 -0
  19. package/src/tool/voice-spectrogram-analyzer/i18n/fr.ts +135 -0
  20. package/src/tool/voice-spectrogram-analyzer/i18n/id.ts +136 -0
  21. package/src/tool/voice-spectrogram-analyzer/i18n/it.ts +135 -0
  22. package/src/tool/voice-spectrogram-analyzer/i18n/ja.ts +135 -0
  23. package/src/tool/voice-spectrogram-analyzer/i18n/ko.ts +135 -0
  24. package/src/tool/voice-spectrogram-analyzer/i18n/nl.ts +136 -0
  25. package/src/tool/voice-spectrogram-analyzer/i18n/pl.ts +136 -0
  26. package/src/tool/voice-spectrogram-analyzer/i18n/pt.ts +135 -0
  27. package/src/tool/voice-spectrogram-analyzer/i18n/ru.ts +136 -0
  28. package/src/tool/voice-spectrogram-analyzer/i18n/sv.ts +136 -0
  29. package/src/tool/voice-spectrogram-analyzer/i18n/tr.ts +136 -0
  30. package/src/tool/voice-spectrogram-analyzer/i18n/zh.ts +135 -0
  31. package/src/tool/voice-spectrogram-analyzer/index.ts +11 -0
  32. package/src/tool/voice-spectrogram-analyzer/logic.test.ts +46 -0
  33. package/src/tool/voice-spectrogram-analyzer/logic.ts +163 -0
  34. package/src/tool/voice-spectrogram-analyzer/seo.astro +15 -0
  35. package/src/tool/voice-spectrogram-analyzer/storage.ts +33 -0
  36. package/src/tool/voice-spectrogram-analyzer/ui.ts +49 -0
  37. package/src/tool/voice-spectrogram-analyzer/voice-spectrogram-analyzer.css +547 -0
  38. package/src/tools.ts +3 -1
@@ -0,0 +1,135 @@
1
+ import { bibliography } from '../bibliography';
2
+ import type { VoiceSpectrogramLocaleContent } from '../entry';
3
+
4
+ const slug = "voice-spectrogram-analyzer-online";
5
+ const title = "在线声音语图频谱分析仪";
6
+ const description = "在浏览器中私密可视化两段音频样品的频率、时间、音强及估计共振峰(Formant)特征。";
7
+
8
+ const howTo = [
9
+ {
10
+ "name": "选择两段音频样品",
11
+ "text": "加载本地音频或使用合成元音示例。"
12
+ },
13
+ {
14
+ "name": "设置频率上限",
15
+ "text": "根据声音类型选择 4、6 或 8 kHz。"
16
+ },
17
+ {
18
+ "name": "解读语图频谱",
19
+ "text": "观察时间、频率分布及共振峰特征。"
20
+ },
21
+ {
22
+ "name": "播放并对比",
23
+ "text": "结合同步光标聆听并对比 F1、F2、F3 平均值。"
24
+ }
25
+ ];
26
+
27
+ const faq = [
28
+ {
29
+ "question": "声音语图(Spectrogram)展示了什么?",
30
+ "answer": "语图横轴代表时间,纵轴代表频率,颜色的亮度代表声音信号的能量强度。"
31
+ },
32
+ {
33
+ "question": "我的录音会被上传吗?",
34
+ "answer": "不会。所有解码与分析均在您的浏览器本地进行。"
35
+ },
36
+ {
37
+ "question": "F1、F2 和 F3 参考线是什么?",
38
+ "answer": "它们是对声道前三个主要共振峰(Formant)位置的教学估计。"
39
+ },
40
+ {
41
+ "question": "此工具能否用于说话人身份鉴定?",
42
+ "answer": "不能。视觉相似度或共振峰接近度不能作为法庭声纹身份证明。"
43
+ },
44
+ {
45
+ "question": "为什么更改频率上限会影响共振峰估计?",
46
+ "answer": "频率显示范围的变化会改变频谱峰值的分离与平滑效果。"
47
+ }
48
+ ];
49
+
50
+ export const content: VoiceSpectrogramLocaleContent = {
51
+ slug,
52
+ title,
53
+ description,
54
+ ui: {
55
+ "privacyBadge": "仅限本地",
56
+ "privacyNote": "您的录音文件保存在本地设备中。解码与频谱分析完全在浏览器内运行。",
57
+ "loadHeading": "加载两段音频进行对比分析",
58
+ "sampleALabel": "样品 A",
59
+ "sampleBLabel": "样品 B",
60
+ "chooseFileLabel": "选择音频",
61
+ "replaceFileLabel": "更换音频",
62
+ "dropHint": "将音频文件拖放到此处(最大 25 MB)。将分析前 20 秒。",
63
+ "presetHint": "直接使用预设的合成元音研究样品。",
64
+ "presetWarmLabel": "暖音元音研究",
65
+ "presetBrightLabel": "亮音元音研究",
66
+ "emptySampleLabel": "等待音频输入",
67
+ "readySampleLabel": "频谱图生成完毕",
68
+ "decodingSampleLabel": "正在生成频谱图",
69
+ "errorSampleLabel": "无法分析该音频样品",
70
+ "durationLabel": "时长",
71
+ "ceilingHeading": "频率上限设置",
72
+ "ceilingFourLabel": "4 kHz",
73
+ "ceilingSixLabel": "6 kHz",
74
+ "ceilingEightLabel": "8 kHz",
75
+ "stageLabel": "镜像声音语图展示台",
76
+ "mirrorViewLabel": "镜像对照",
77
+ "splitViewLabel": "平行对照",
78
+ "playALabel": "播放样品 A",
79
+ "playBLabel": "播放样品 B",
80
+ "stopLabel": "停止播放",
81
+ "timeAxisLabel": "时间",
82
+ "frequencyAxisLabel": "频率",
83
+ "intensityLegendLabel": "颜色越亮代表声音能量越强",
84
+ "formantLegendLabel": "估计共振峰参考线",
85
+ "sampleAEmptyCanvasLabel": "加载样品 A 以显示其语图频谱",
86
+ "sampleBEmptyCanvasLabel": "加载样品 B 以显示其语图频谱",
87
+ "comparisonHeading": "声道共振特征分析",
88
+ "comparisonNote": "浊音段的平均频谱峰值位置。差值为物理测量结果,不代表相似度百分比。",
89
+ "formantOneLabel": "第一共振区 (F1)",
90
+ "formantTwoLabel": "第二共振区 (F2)",
91
+ "formantThreeLabel": "第三共振区 (F3)",
92
+ "averageLabel": "平均值",
93
+ "differenceLabel": "差值",
94
+ "unavailableLabel": "不可用",
95
+ "statusEmptyLabel": "加载音频样品以开始",
96
+ "statusSingleLabel": "一个样品已准备就绪",
97
+ "statusReadyLabel": "两个样品的频谱图均已就绪",
98
+ "limitError": "文件大小超过了 25 MB 的本地分析限制。",
99
+ "decodeError": "浏览器无法解码此音频格式。",
100
+ "browserError": "当前浏览器不支持 Web Audio API。",
101
+ "educationalNote": "仅用于教学与信号可视化。共振峰参考线基于简化的频谱峰值平滑算法,不能用于司法声纹鉴定或生物特征识别。"
102
+ },
103
+ seo: [
104
+ { type: 'title', text: "语图如何将声音信号转化为视觉图谱", level: 2 },
105
+ { type: 'paragraph', html: "<strong>声音语图(Spectrogram)</strong>将录音信号转化为横轴为时间、纵轴为频率的视觉图谱。强音响能量以更亮的颜色呈现。这使得持续母音、谐波、静音及共振峰的变化比普通波形图更易于观察与分析。直观的图谱极大降低了声音分析的门槛。" },
106
+ { type: 'paragraph', html: "分析仪将信号切分为重叠的短帧,应用汉明窗(Hamming Window)并通过快速傅里叶变换(FFT)计算各频率的能量分布。短帧能精准定位事件发生的时间,而频率分辨率则揭示能量集中的位置。受信号处理不确定性原理限制,时间与频率分辨率之间始终存在权衡。" },
107
+ { type: 'diagnostic', variant: 'info', title: "浏览器本地私密处理", html: "在浏览器中私密可视化两段音频样品的频率、时间、音强及估计共振峰(Formant)特征。" },
108
+ { type: 'stats', columns: 3, items: [
109
+ { value: "时间", label: "从左至右读取时间轴" },
110
+ { value: "Hz", label: "频率位置" },
111
+ { value: "能量", label: "通过颜色亮度呈现" }
112
+ ] },
113
+ { type: 'title', text: "理性解读共振峰数据", level: 3 },
114
+ { type: 'paragraph', html: "共振峰(Formant)是由声道形状塑造的共振区域。在语音学中,F1 和 F2 常用于描述母音的高度与发音位置。本分析仪追踪三个频率区域内的平滑峰值,帮助使用者将视觉谱带与 F1、F2、F3 的动态变化联系起来。" },
115
+ { type: 'paragraph', html: "专业共振峰测量通常采用针对说话人定制的线性预测编码(LPC)算法。基音谐波、鼻音化、房间混响及背景噪声均可能导致简易峰值估计产生偏差。请将辅助线作为教学参考,并结合背景中的实际语图进行综合判断。" },
116
+ { type: 'table', headers: ['Guide', 'Region', 'Meaning'], rows: [["F1","180 至 1000 Hz","第一共振区,与开口度及母音高度相关"],["F2","900 至 3000 Hz","第二共振区,与舌位前后相关"],["F3","2000 至 4500 Hz","高阶共振区,受声道整体几何形状影响"]] },
117
+ { type: 'title', text: "频率设置对语图分析的影响", level: 3 },
118
+ { type: 'comparative', columns: 2, items: [
119
+ { title: "较低上限 (4 kHz)", description: "更清晰展示低频声道特征", points: ["适合观察母音", "可能排除高频能量", "并不保证更高精度"] },
120
+ { title: "较高上限 (6/8 kHz)", description: "展示更多高频细节", highlight: true, points: ["适合高音调语音", "清晰显示摩擦音", "垂直压缩低频区域"] }
121
+ ] },
122
+ { type: 'title', text: "两段语音样品的科学对比方法", level: 3 },
123
+ { type: 'paragraph', html: "对比两组图谱在两段录音包含相同母音或短语且录音环境相似时最为有效。显示的差值为峰值位置之间的绝对物理测量值,并不代表相似度百分比或生物特征身份证明。" },
124
+ { type: 'list', items: ["<strong>对比相同的发音内容:</strong> 重复的母音或单词比不同短语更容易对比。","<strong>保持录音条件一致:</strong> 麦克风类型和房间声学对频谱有显著影响。","<strong>结合光标同步聆听:</strong> 将视觉特征与对应的声音时刻精准关联。","<strong>避免做出身份认定:</strong> 相似的语图并不能证明两段录音来自同一说话人。"] },
125
+ { type: 'summary', title: "语图分析仪核心要点总结", items: ["在浏览器本地从兼容音频文件中生成声音语图。","在镜像或平行图板中双样对照分析。","学习声学能量与估计共振峰区域的动态变化。","保持描述性与教学导向,而非司法鉴定。"] }
126
+ ],
127
+ faq,
128
+ bibliography,
129
+ howTo,
130
+ schemas: [
131
+ { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: title, description, applicationCategory: 'MultimediaApplication', operatingSystem: 'Any' },
132
+ { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) },
133
+ { '@context': 'https://schema.org', '@type': 'HowTo', name: title, step: howTo.map((step) => ({ '@type': 'HowToStep', name: step.name, text: step.text })) }
134
+ ]
135
+ };
@@ -0,0 +1,11 @@
1
+ import type { ToolDefinition } from '../../types';
2
+ import { voiceSpectrogramAnalyzer } from './entry';
3
+
4
+ export * from './entry';
5
+
6
+ export const VOICE_SPECTROGRAM_ANALYZER_TOOL: ToolDefinition = {
7
+ entry: voiceSpectrogramAnalyzer,
8
+ Component: () => import('./component.astro'),
9
+ SEOComponent: () => import('./seo.astro'),
10
+ BibliographyComponent: () => import('./bibliography.astro')
11
+ };
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createHammingWindow, fftMagnitudes } from './fft';
3
+ import { analyzeSamples, compareFormants, createSyntheticVowel, formatDuration } from './logic';
4
+
5
+ describe('voice spectrogram signal logic', () => {
6
+ it('creates a symmetric Hamming window', () => {
7
+ const window = createHammingWindow(8);
8
+ expect(window).toHaveLength(8);
9
+ expect(window[0]!).toBeCloseTo(window[7]!);
10
+ expect(window[3]!).toBeGreaterThan(window[0]!);
11
+ });
12
+
13
+ it('places a pure tone in the expected FFT bin', () => {
14
+ const size = 1024;
15
+ const signal = Float32Array.from({ length: size }, (_, index) => Math.sin((2 * Math.PI * 32 * index) / size));
16
+ const magnitudes = fftMagnitudes(signal);
17
+ const peak = [...magnitudes].indexOf(Math.max(...magnitudes));
18
+ expect(peak).toBe(32);
19
+ });
20
+
21
+ it('analyzes a short synthetic vowel into bounded frames and formants', () => {
22
+ const samples = createSyntheticVowel({ fundamental: 120, formants: [650, 1150, 2500], duration: 2.4, sampleRate: 16000 });
23
+ const analysis = analyzeSamples(samples, 16000, 6000);
24
+ expect(analysis.frames.length).toBeGreaterThan(10);
25
+ expect(analysis.frames.length).toBeLessThanOrEqual(220);
26
+ expect(analysis.frames[0]!.energy).toHaveLength(144);
27
+ expect(analysis.averages).not.toBeNull();
28
+ expect(analysis.averages![2]).toBeGreaterThan(analysis.averages![1]);
29
+ expect(analysis.duration).toBeCloseTo(2.4);
30
+ });
31
+
32
+ it('handles silence and audio shorter than an FFT frame', () => {
33
+ const analysis = analyzeSamples(new Float32Array(200), 8000, 4000);
34
+ expect(analysis.frames).toHaveLength(1);
35
+ expect(analysis.averages).toBeNull();
36
+ });
37
+
38
+ it('reports absolute formant differences without an identity score', () => {
39
+ expect(compareFormants([500, 1400, 2500], [620, 1300, 2800])).toEqual([120, 100, 300]);
40
+ expect(compareFormants(null, [620, 1300, 2800])).toBeNull();
41
+ });
42
+
43
+ it('formats durations consistently', () => {
44
+ expect(formatDuration(65.9)).toBe('1:05');
45
+ });
46
+ });
@@ -0,0 +1,163 @@
1
+ import { createHammingWindow, fftMagnitudes } from './fft';
2
+
3
+ export type FormantTriplet = [number, number, number];
4
+
5
+ export interface SpectrogramFrame {
6
+ energy: number[];
7
+ formants: FormantTriplet | null;
8
+ }
9
+
10
+ export interface SpectrogramAnalysis {
11
+ frames: SpectrogramFrame[];
12
+ averages: FormantTriplet | null;
13
+ duration: number;
14
+ ceilingHz: number;
15
+ sampleRate: number;
16
+ }
17
+
18
+ export interface SyntheticVowel {
19
+ fundamental: number;
20
+ formants: FormantTriplet;
21
+ duration: number;
22
+ sampleRate: number;
23
+ }
24
+
25
+ const DISPLAY_BINS = 144;
26
+ const MAX_FRAMES = 220;
27
+
28
+ function clamp(value: number, minimum: number, maximum: number): number {
29
+ return Math.min(maximum, Math.max(minimum, value));
30
+ }
31
+
32
+ function frameSizeFor(sampleRate: number): number {
33
+ return sampleRate >= 16000 ? 2048 : 1024;
34
+ }
35
+
36
+ function buildStarts(length: number, frameSize: number): number[] {
37
+ if (length <= frameSize) return [0];
38
+ const available = length - frameSize;
39
+ const hop = Math.max(Math.floor(frameSize / 4), Math.ceil(available / (MAX_FRAMES - 1)));
40
+ const starts: number[] = [];
41
+ for (let start = 0; start <= available; start += hop) starts.push(start);
42
+ if (starts.at(-1) !== available) starts.push(available);
43
+ return starts.slice(0, MAX_FRAMES);
44
+ }
45
+
46
+ function windowFrame(samples: Float32Array, start: number, size: number, window: Float32Array): Float32Array {
47
+ const frame = new Float32Array(size);
48
+ for (let index = 0; index < size; index += 1) frame[index] = (samples[start + index] ?? 0) * window[index]!;
49
+ return frame;
50
+ }
51
+
52
+ function rms(frame: Float32Array): number {
53
+ let sum = 0;
54
+ for (const value of frame) sum += value * value;
55
+ return Math.sqrt(sum / frame.length);
56
+ }
57
+
58
+ function smooth(magnitudes: Float64Array, radius: number): Float64Array {
59
+ const output = new Float64Array(magnitudes.length);
60
+ for (let index = 0; index < magnitudes.length; index += 1) {
61
+ let sum = 0;
62
+ let count = 0;
63
+ for (let offset = -radius; offset <= radius; offset += 1) {
64
+ const value = magnitudes[index + offset];
65
+ if (value === undefined) continue;
66
+ sum += value;
67
+ count += 1;
68
+ }
69
+ output[index] = sum / count;
70
+ }
71
+ return output;
72
+ }
73
+
74
+ function peakInRange(values: Float64Array, hzPerBin: number, minimum: number, maximum: number): number {
75
+ const start = clamp(Math.ceil(minimum / hzPerBin), 1, values.length - 2);
76
+ const end = clamp(Math.floor(maximum / hzPerBin), start, values.length - 2);
77
+ let best = start;
78
+ for (let index = start + 1; index <= end; index += 1) {
79
+ if (values[index]! > values[best]!) best = index;
80
+ }
81
+ return Math.round(best * hzPerBin);
82
+ }
83
+
84
+ function estimateFormants(magnitudes: Float64Array, sampleRate: number, frameRms: number): FormantTriplet | null {
85
+ if (frameRms < 0.004) return null;
86
+ const envelope = smooth(magnitudes, 5);
87
+ const hzPerBin = sampleRate / (magnitudes.length * 2);
88
+ const first = peakInRange(envelope, hzPerBin, 180, 1000);
89
+ const second = peakInRange(envelope, hzPerBin, Math.max(900, first + 180), 3000);
90
+ const third = peakInRange(envelope, hzPerBin, Math.max(2000, second + 240), 4500);
91
+ return [first, second, third];
92
+ }
93
+
94
+ function displayEnergy(magnitudes: Float64Array, sampleRate: number, ceilingHz: number): number[] {
95
+ const hzPerBin = sampleRate / (magnitudes.length * 2);
96
+ const output: number[] = [];
97
+ for (let bin = 0; bin < DISPLAY_BINS; bin += 1) {
98
+ const frequency = (bin / (DISPLAY_BINS - 1)) * ceilingHz;
99
+ const index = clamp(Math.round(frequency / hzPerBin), 0, magnitudes.length - 1);
100
+ const decibels = 20 * Math.log10(magnitudes[index]! + 1e-8);
101
+ output.push(clamp((decibels + 82) / 68, 0, 1));
102
+ }
103
+ return output;
104
+ }
105
+
106
+ function averageFormants(frames: SpectrogramFrame[]): FormantTriplet | null {
107
+ const active = frames.flatMap((frame) => frame.formants ? [frame.formants] : []);
108
+ if (active.length === 0) return null;
109
+ const total: FormantTriplet = [0, 0, 0];
110
+ for (const formants of active) {
111
+ total[0] += formants[0];
112
+ total[1] += formants[1];
113
+ total[2] += formants[2];
114
+ }
115
+ return total.map((value) => Math.round(value / active.length)) as FormantTriplet;
116
+ }
117
+
118
+ export function analyzeSamples(samples: Float32Array, sampleRate: number, ceilingHz: number): SpectrogramAnalysis {
119
+ const frameSize = frameSizeFor(sampleRate);
120
+ const window = createHammingWindow(frameSize);
121
+ const frames = buildStarts(samples.length, frameSize).map((start) => {
122
+ const frame = windowFrame(samples, start, frameSize, window);
123
+ const magnitudes = fftMagnitudes(frame);
124
+ return {
125
+ energy: displayEnergy(magnitudes, sampleRate, ceilingHz),
126
+ formants: estimateFormants(magnitudes, sampleRate, rms(frame))
127
+ };
128
+ });
129
+ return { frames, averages: averageFormants(frames), duration: samples.length / sampleRate, ceilingHz, sampleRate };
130
+ }
131
+
132
+ function fadeEnvelope(time: number, duration: number): number {
133
+ const attack = clamp(time / 0.12, 0, 1);
134
+ const release = clamp((duration - time) / 0.2, 0, 1);
135
+ return Math.min(attack, release) * (0.82 + (0.18 * Math.sin(Math.PI * time / duration)));
136
+ }
137
+
138
+ export function createSyntheticVowel(config: SyntheticVowel): Float32Array {
139
+ const length = Math.floor(config.duration * config.sampleRate);
140
+ const output = new Float32Array(length);
141
+ const weights = [0.6, 0.38, 0.24];
142
+ for (let index = 0; index < length; index += 1) {
143
+ const time = index / config.sampleRate;
144
+ const drift = 1 + (0.012 * Math.sin(time * Math.PI * 0.8));
145
+ let value = 0.18 * Math.sin(2 * Math.PI * config.fundamental * time);
146
+ config.formants.forEach((frequency, formantIndex) => {
147
+ value += weights[formantIndex]! * Math.sin(2 * Math.PI * frequency * drift * time);
148
+ });
149
+ output[index] = value * fadeEnvelope(time, config.duration) * 0.72;
150
+ }
151
+ return output;
152
+ }
153
+
154
+ export function compareFormants(first: FormantTriplet | null, second: FormantTriplet | null): FormantTriplet | null {
155
+ if (!first || !second) return null;
156
+ return first.map((value, index) => Math.abs(value - second[index]!)) as FormantTriplet;
157
+ }
158
+
159
+ export function formatDuration(seconds: number): string {
160
+ const minutes = Math.floor(seconds / 60);
161
+ const remainder = Math.floor(seconds % 60).toString().padStart(2, '0');
162
+ return `${minutes}:${remainder}`;
163
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { SEORenderer } 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
+ if (!content) return null;
13
+ ---
14
+
15
+ {content.seo.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,33 @@
1
+ export type SpectrogramView = 'mirror' | 'split';
2
+
3
+ export interface SpectrogramSettings {
4
+ ceilingHz: number;
5
+ view: SpectrogramView;
6
+ }
7
+
8
+ const STORAGE_KEY = 'voice-spectrogram-analyzer-settings';
9
+ const DEFAULTS: SpectrogramSettings = { ceilingHz: 6000, view: 'mirror' };
10
+
11
+ function isSettings(value: unknown): value is SpectrogramSettings {
12
+ if (!value || typeof value !== 'object') return false;
13
+ const candidate = value as Partial<SpectrogramSettings>;
14
+ return [4000, 6000, 8000].includes(candidate.ceilingHz ?? 0)
15
+ && ['mirror', 'split'].includes(candidate.view ?? '');
16
+ }
17
+
18
+ export function loadSettings(): SpectrogramSettings {
19
+ try {
20
+ const stored = localStorage.getItem(STORAGE_KEY);
21
+ if (!stored) return DEFAULTS;
22
+ const parsed: unknown = JSON.parse(stored);
23
+ return isSettings(parsed) ? parsed : DEFAULTS;
24
+ } catch {
25
+ return DEFAULTS;
26
+ }
27
+ }
28
+
29
+ export function saveSettings(settings: SpectrogramSettings): void {
30
+ try {
31
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
32
+ } catch {}
33
+ }
@@ -0,0 +1,49 @@
1
+ export interface VoiceSpectrogramUI extends Record<string, string> {
2
+ privacyBadge: string;
3
+ privacyNote: string;
4
+ loadHeading: string;
5
+ sampleALabel: string;
6
+ sampleBLabel: string;
7
+ chooseFileLabel: string;
8
+ replaceFileLabel: string;
9
+ dropHint: string;
10
+ presetHint: string;
11
+ presetWarmLabel: string;
12
+ presetBrightLabel: string;
13
+ emptySampleLabel: string;
14
+ readySampleLabel: string;
15
+ decodingSampleLabel: string;
16
+ errorSampleLabel: string;
17
+ durationLabel: string;
18
+ ceilingHeading: string;
19
+ ceilingFourLabel: string;
20
+ ceilingSixLabel: string;
21
+ ceilingEightLabel: string;
22
+ stageLabel: string;
23
+ mirrorViewLabel: string;
24
+ splitViewLabel: string;
25
+ playALabel: string;
26
+ playBLabel: string;
27
+ stopLabel: string;
28
+ timeAxisLabel: string;
29
+ frequencyAxisLabel: string;
30
+ intensityLegendLabel: string;
31
+ formantLegendLabel: string;
32
+ sampleAEmptyCanvasLabel: string;
33
+ sampleBEmptyCanvasLabel: string;
34
+ comparisonHeading: string;
35
+ comparisonNote: string;
36
+ formantOneLabel: string;
37
+ formantTwoLabel: string;
38
+ formantThreeLabel: string;
39
+ averageLabel: string;
40
+ differenceLabel: string;
41
+ unavailableLabel: string;
42
+ statusEmptyLabel: string;
43
+ statusSingleLabel: string;
44
+ statusReadyLabel: string;
45
+ limitError: string;
46
+ decodeError: string;
47
+ browserError: string;
48
+ educationalNote: string;
49
+ }