@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
2
|
+
import type { VoiceSpectrogramLocaleContent } from '../entry';
|
|
3
|
+
|
|
4
|
+
const slug = "stimmen-spektrogramm-analysator-online";
|
|
5
|
+
const title = "Stimmen Spektrogramm Analysator Online";
|
|
6
|
+
const description = "Visualisieren Sie Frequenz, Zeit, Intensität und Formanten von zwei Audiodateien lokal und privat in Ihrem Browser.";
|
|
7
|
+
|
|
8
|
+
const howTo = [
|
|
9
|
+
{
|
|
10
|
+
"name": "Zwei Proben wählen",
|
|
11
|
+
"text": "Laden Sie lokale Audiodateien oder nutzen Sie die synthetischen Vokalstudien."
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "Frequenz-Obergrenze festlegen",
|
|
15
|
+
"text": "Wählen Sie 4, 6 oder 8 kHz passend zum Stimmumfang."
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "Spektralplatten ablesen",
|
|
19
|
+
"text": "Inspezieren Sie Zeit, Frequenz, Intensität und Formanten."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "Anhören und vergleichen",
|
|
23
|
+
"text": "Spielen Sie die Proben ab und vergleichen Sie die Durchschnittswerte."
|
|
24
|
+
}
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const faq = [
|
|
28
|
+
{
|
|
29
|
+
"question": "Was zeigt ein Spektrogramm der Stimme?",
|
|
30
|
+
"answer": "Ein Spektrogramm stellt Zeit auf der horizontalen Achse, Frequenz auf der vertikalen Achse und Signalintensität durch Helligkeit dar."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"question": "Werden meine Aufnahmen hochgeladen?",
|
|
34
|
+
"answer": "Nein. Alle Berechnungen erfolgen vollständig lokal im Browser."
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"question": "Was bedeuten F1, F2 und F3?",
|
|
38
|
+
"answer": "Es sind Schätzungen der ersten drei Hauptresonanzen des Vokaltrakts."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"question": "Kann das Tool einen Sprecher identifizieren?",
|
|
42
|
+
"answer": "Nein. Visuelle Ähnlichkeiten ersetzen kein forensisches Gutachten."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"question": "Warum ändern sich die Formanten bei anderer Frequenzgrenze?",
|
|
46
|
+
"answer": "Die Wahl des Frequenzbereichs verändert die sichtbaren Resonanzspitzen."
|
|
47
|
+
}
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export const content: VoiceSpectrogramLocaleContent = {
|
|
51
|
+
slug,
|
|
52
|
+
title,
|
|
53
|
+
description,
|
|
54
|
+
ui: {
|
|
55
|
+
"privacyBadge": "Nur lokal",
|
|
56
|
+
"privacyNote": "Ihre Aufnahmen bleiben auf Ihrem Gerät. Analyse und Dekodierung laufen direkt im Browser.",
|
|
57
|
+
"loadHeading": "Zwei Audiodateien zum Vergleichen laden",
|
|
58
|
+
"sampleALabel": "Probe A",
|
|
59
|
+
"sampleBLabel": "Probe B",
|
|
60
|
+
"chooseFileLabel": "Audio auswählen",
|
|
61
|
+
"replaceFileLabel": "Audio ersetzen",
|
|
62
|
+
"dropHint": "Ziehen Sie eine Audiodatei hierher (max. 25 MB). Die ersten 20 Sekunden werden analysiert.",
|
|
63
|
+
"presetHint": "Starten Sie sofort mit den zwei synthetischen Vokalstudien.",
|
|
64
|
+
"presetWarmLabel": "Warme Vokalstudie",
|
|
65
|
+
"presetBrightLabel": "Helle Vokalstudie",
|
|
66
|
+
"emptySampleLabel": "Warten auf Audio",
|
|
67
|
+
"readySampleLabel": "Spektralplatte erstellt",
|
|
68
|
+
"decodingSampleLabel": "Spektralplatte wird berechnet",
|
|
69
|
+
"errorSampleLabel": "Probe konnte nicht analysiert werden",
|
|
70
|
+
"durationLabel": "Dauer",
|
|
71
|
+
"ceilingHeading": "Frequenz-Obergrenze",
|
|
72
|
+
"ceilingFourLabel": "4 kHz",
|
|
73
|
+
"ceilingSixLabel": "6 kHz",
|
|
74
|
+
"ceilingEightLabel": "8 kHz",
|
|
75
|
+
"stageLabel": "Spiegel-Spektrogramm-Bühne",
|
|
76
|
+
"mirrorViewLabel": "Gegenübergestellt",
|
|
77
|
+
"splitViewLabel": "Parallel",
|
|
78
|
+
"playALabel": "Probe A abspielen",
|
|
79
|
+
"playBLabel": "Probe B abspielen",
|
|
80
|
+
"stopLabel": "Stoppen",
|
|
81
|
+
"timeAxisLabel": "Zeit",
|
|
82
|
+
"frequencyAxisLabel": "Frequenz",
|
|
83
|
+
"intensityLegendLabel": "Hellere Tinte bedeutet höhere Energie",
|
|
84
|
+
"formantLegendLabel": "Geschätzte Formant-Linien",
|
|
85
|
+
"sampleAEmptyCanvasLabel": "Laden Sie Probe A für das Spektrogramm",
|
|
86
|
+
"sampleBEmptyCanvasLabel": "Laden Sie Probe B für das Spektrogramm",
|
|
87
|
+
"comparisonHeading": "Analyse der Resonanzmuster",
|
|
88
|
+
"comparisonNote": "Durchschnittliche Spektralspitzen in stimmhaften Abschnitten. Die Abweichungen sind Messwerte, kein Übereinstimmungsprozentsatz.",
|
|
89
|
+
"formantOneLabel": "Erste Resonanzregion (F1)",
|
|
90
|
+
"formantTwoLabel": "Zweite Resonanzregion (F2)",
|
|
91
|
+
"formantThreeLabel": "Dritte Resonanzregion (F3)",
|
|
92
|
+
"averageLabel": "Durchschnitt",
|
|
93
|
+
"differenceLabel": "Differenz",
|
|
94
|
+
"unavailableLabel": "Nicht verfügbar",
|
|
95
|
+
"statusEmptyLabel": "Laden Sie eine Probe zum Starten",
|
|
96
|
+
"statusSingleLabel": "Eine Spektralplatte ist bereit",
|
|
97
|
+
"statusReadyLabel": "Beide Spektralplatten sind bereit",
|
|
98
|
+
"limitError": "Die Datei überschreitet das lokale Limit von 25 MB.",
|
|
99
|
+
"decodeError": "Das Audioformat konnte vom Browser nicht dekodiert werden.",
|
|
100
|
+
"browserError": "Web Audio API ist in diesem Browser nicht verfügbar.",
|
|
101
|
+
"educationalNote": "Didaktisches Signalwerkzeug. Formantlinien dienen der Veranschaulichung und sind nicht für forensische Gutachten geeignet."
|
|
102
|
+
},
|
|
103
|
+
seo: [
|
|
104
|
+
{ type: 'title', text: "Wie ein Spektrogramm Schall in ein visuelles Bild verwandelt", level: 2 },
|
|
105
|
+
{ type: 'paragraph', html: "Ein <strong>Stimmen-Spektrogramm</strong> verwandelt eine Aufnahme in eine Karte mit Zeit auf der horizontalen Achse und Frequenz auf der vertikalen Achse. Stärkere Energie erscheint als hellere Farbe. Dies macht anhaltende Vokale, Obereltöne, Stille und Resonanzen leichter erkennbar als in einer einfachen Wellenform. Die Visualisierung ermöglicht eine direkte und detaillierte Analyse von Sprachmustern im Frequenzbereich. Dadurch lassen sich akustische Merkmale präzise untersuchen." },
|
|
106
|
+
{ type: 'paragraph', html: "Der Analysator unterteilt das Signal in kurze überlappende Abschnitte, wendet ein Hamming-Fenster an und berechnet die Energieverteilung über die Frequenzen mittels FFT. Ein kurzer Abschnitt bestimmt den genauen Zeitpunkt, während die Frequenzauflösung zeigt, wo sich die Energie konzentriert. Aufgrund der Unschärferelation der Signalverarbeitung gibt es immer einen Kompromiss zwischen Zeit- und Frequenzauflösung. Diese Eigenschaften bestimmen die visuelle Schärfe." },
|
|
107
|
+
{ type: 'diagnostic', variant: 'info', title: "Private Verarbeitung im Browser", html: "Visualisieren Sie Frequenz, Zeit, Intensität und Formanten von zwei Audiodateien lokal und privat in Ihrem Browser." },
|
|
108
|
+
{ type: 'stats', columns: 3, items: [
|
|
109
|
+
{ value: "Zeit", label: "Von links nach rechts lesen" },
|
|
110
|
+
{ value: "Hz", label: "Frequenzposition" },
|
|
111
|
+
{ value: "Energie", label: "Als Helligkeit dargestellt" }
|
|
112
|
+
] },
|
|
113
|
+
{ type: 'title', text: "Formanten lesen ohne Ergebnisse zu übertreiben", level: 3 },
|
|
114
|
+
{ type: 'paragraph', html: "Formanten sind Resonanzbereiche, die durch den Vokaltrakt geformt werden. F1 und F2 werden in der Phonetik verwendet, um die Vokalhöhe und den Artikulationsort zu beschreiben. Dieser Analysator verfolgt breite Spektralspitzen in drei Frequenzbereichen, damit Benutzer sichtbare Bänder mit dem Verhalten von F1, F2 und F3 verbinden können." },
|
|
115
|
+
{ type: 'paragraph', html: "Eine professionelle Formantmessung nutzt normalerweise ein gewichtetes Linear Prediction Coding Verfahren und passt die Frequenzobergrenze an den Sprecher an. Grundtonharmonische, Nasalierung, Raumhall und Hintergrundgeräusche können einfache Schätzungen verschieben. Nutzen Sie diese Linien als didaktische Orientierung und prüfen Sie stets das visuelle Spektrum im Hintergrund." },
|
|
116
|
+
{ type: 'table', headers: ['Guide', 'Region', 'Meaning'], rows: [["F1","180 bis 1000 Hz","Erste Resonanzregion, verbunden mit Vokalöffnung"],["F2","900 bis 3000 Hz","Zweite Resonanzregion, verbunden mit der Zungenposition"],["F3","2000 bis 4500 Hz","Höhere Resonanzregion, beeinflusst durch die Geometrie des Vokaltrakts"]] },
|
|
117
|
+
{ type: 'title', text: "Warum Frequenzgrenzen das Bild verändern", level: 3 },
|
|
118
|
+
{ type: 'comparative', columns: 2, items: [
|
|
119
|
+
{ title: "Niedrige Grenze (4 kHz)", description: "Bessere Sicht auf untere Frequenzen", points: ["Nützlich für Vokale", "Kann hohe Energie ausschließen", "Garantiert keine höhere Genauigkeit"] },
|
|
120
|
+
{ title: "Hohe Grenze (6/8 kHz)", description: "Mehr obere Details", highlight: true, points: ["Für helle Stimmen", "Zeigt Reibelaute", "Komprimiert untere Bänder"] }
|
|
121
|
+
] },
|
|
122
|
+
{ type: 'title', text: "Ein verantwortungsvoller Vergleich zweier Proben", level: 3 },
|
|
123
|
+
{ type: 'paragraph', html: "Der Vergleich zweier Platten ist am nützlichsten, wenn die Aufnahmen denselben Vokal oder Satz unter ähnlichen akustischen Bedingungen enthalten. Die angezeigten Abweichungen sind absolute Differenzen zwischen Spektralspitzen. Sie stellen kein Übereinstimmungsprozent und keinen biometrischen Identitätsbeweis dar." },
|
|
124
|
+
{ type: 'list', items: ["<strong>Gleichen Inhalt vergleichen:</strong> Wiederholte Vokale oder Wörter lassen sich leichter vergleichen als unterschiedliche Sätze.","<strong>Ähnliche Aufnahmebedingungen nutzen:</strong> Mikrofon und Raumakustik beeinflussen das Spektrum maßgeblich.","<strong>Mit dem Cursor zuhören:</strong> Verbinden Sie visuelle Ereignisse mit dem exakten Klangmoment.","<strong>Keine Identitätsansprüche stellen:</strong> Ein ähnlich aussehendes Spektrogramm beweist keine Sprecheridentität."] },
|
|
125
|
+
{ type: 'summary', title: "Zusammenfassung des Analysators", items: ["Erstellen Sie ein Audiospektrogramm lokal aus dateibasierten Formaten.","Erkunden Sie zwei Proben in gespiegelten oder parallelen Platten.","Lernen Sie, wie sich spektrale Energie und Formantbereiche verändern.","Halten Sie Vergleiche beschreibend und didaktisch anstatt forensisch."] }
|
|
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,122 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
2
|
+
import type { VoiceSpectrogramLocaleContent } from '../entry';
|
|
3
|
+
|
|
4
|
+
const slug = 'voice-spectrogram-analyzer-online';
|
|
5
|
+
const title = 'Voice Spectrogram Analyzer Online';
|
|
6
|
+
const description = 'Reveal the frequency, time, intensity, and estimated formant patterns of two audio samples privately in your browser.';
|
|
7
|
+
|
|
8
|
+
const howTo = [
|
|
9
|
+
{ name: 'Choose Two Audio Samples', text: 'Load one or two local audio files, drag them onto the sample chambers, or begin with the included synthetic vowel studies.' },
|
|
10
|
+
{ name: 'Set the Frequency Ceiling', text: 'Choose a 4, 6, or 8 kHz ceiling to frame the frequencies that matter for the material and voice range you are studying.' },
|
|
11
|
+
{ name: 'Read the Spectral Plates', text: 'Inspect time from left to right, frequency vertically, intensity as brightness, and the three fine guides as educational formant peak estimates.' },
|
|
12
|
+
{ name: 'Listen and Compare', text: 'Play either sample to move the synchronized cursor, then compare average F1, F2, and F3 estimates without treating the result as speaker identification.' }
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const faq = [
|
|
16
|
+
{ question: 'What does a voice spectrogram show?', answer: 'A spectrogram maps time horizontally, frequency vertically, and signal intensity through color brightness. Sustained speech resonances often appear as horizontal energy bands.' },
|
|
17
|
+
{ question: 'Are my audio recordings uploaded?', answer: 'No. Audio decoding, spectral analysis, visualization, and playback happen locally in the browser. The tool does not send the selected files to a server.' },
|
|
18
|
+
{ question: 'What are the F1, F2, and F3 guides?', answer: 'They are educational estimates of three broad spectral envelope peaks. F1 and F2 are commonly used to discuss vowel height and vowel place, while F3 can reflect additional vocal tract resonances.' },
|
|
19
|
+
{ question: 'Can this analyzer identify a speaker?', answer: 'No. Visual resemblance or formant proximity cannot establish identity. Forensic voice comparison requires validated methods, suitable recordings, uncertainty assessment, and qualified expert interpretation.' },
|
|
20
|
+
{ question: 'Why can formant estimates change with the ceiling?', answer: 'The selected frequency range changes which spectral peaks are available and how they are separated. Speaker anatomy, vowel, recording quality, pitch, and analysis settings also affect estimates.' }
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export const content: VoiceSpectrogramLocaleContent = {
|
|
24
|
+
slug,
|
|
25
|
+
title,
|
|
26
|
+
description,
|
|
27
|
+
ui: {
|
|
28
|
+
privacyBadge: 'Local only',
|
|
29
|
+
privacyNote: 'Your recordings stay on this device. Decoding and analysis run inside the browser.',
|
|
30
|
+
loadHeading: 'Bring two sounds into the light',
|
|
31
|
+
sampleALabel: 'Sample A',
|
|
32
|
+
sampleBLabel: 'Sample B',
|
|
33
|
+
chooseFileLabel: 'Choose audio',
|
|
34
|
+
replaceFileLabel: 'Replace audio',
|
|
35
|
+
dropHint: 'Drop an audio file here, up to 25 MB. The first 20 seconds are analyzed.',
|
|
36
|
+
presetHint: 'Start instantly with the two synthetic vowel studies.',
|
|
37
|
+
presetWarmLabel: 'Warm vowel study',
|
|
38
|
+
presetBrightLabel: 'Bright vowel study',
|
|
39
|
+
emptySampleLabel: 'Waiting for audio',
|
|
40
|
+
readySampleLabel: 'Spectral plate revealed',
|
|
41
|
+
decodingSampleLabel: 'Developing the spectral plate',
|
|
42
|
+
errorSampleLabel: 'The sample could not be analyzed',
|
|
43
|
+
durationLabel: 'Duration',
|
|
44
|
+
ceilingHeading: 'Frequency ceiling',
|
|
45
|
+
ceilingFourLabel: '4 kHz',
|
|
46
|
+
ceilingSixLabel: '6 kHz',
|
|
47
|
+
ceilingEightLabel: '8 kHz',
|
|
48
|
+
stageLabel: 'Mirrored voice spectrogram stage',
|
|
49
|
+
mirrorViewLabel: 'Mirror plates',
|
|
50
|
+
splitViewLabel: 'Parallel plates',
|
|
51
|
+
playALabel: 'Play sample A',
|
|
52
|
+
playBLabel: 'Play sample B',
|
|
53
|
+
stopLabel: 'Stop',
|
|
54
|
+
timeAxisLabel: 'Time',
|
|
55
|
+
frequencyAxisLabel: 'Frequency',
|
|
56
|
+
intensityLegendLabel: 'Brighter ink means stronger energy',
|
|
57
|
+
formantLegendLabel: 'Estimated formant guides',
|
|
58
|
+
sampleAEmptyCanvasLabel: 'Load sample A to reveal its spectrum',
|
|
59
|
+
sampleBEmptyCanvasLabel: 'Load sample B to reveal its spectrum',
|
|
60
|
+
comparisonHeading: 'Read the resonant fingerprints',
|
|
61
|
+
comparisonNote: 'These values describe average spectral peak positions across voiced frames. Differences are measurements, not a similarity score or identity conclusion.',
|
|
62
|
+
formantOneLabel: 'First resonance region',
|
|
63
|
+
formantTwoLabel: 'Second resonance region',
|
|
64
|
+
formantThreeLabel: 'Third resonance region',
|
|
65
|
+
averageLabel: 'Average',
|
|
66
|
+
differenceLabel: 'Gap',
|
|
67
|
+
unavailableLabel: 'Not available',
|
|
68
|
+
statusEmptyLabel: 'Load a sample to begin',
|
|
69
|
+
statusSingleLabel: 'One plate is ready',
|
|
70
|
+
statusReadyLabel: 'Two spectral plates are ready',
|
|
71
|
+
limitError: 'This file is larger than the 25 MB local analysis limit.',
|
|
72
|
+
decodeError: 'This browser could not decode the selected audio format.',
|
|
73
|
+
browserError: 'Web Audio is unavailable in this browser.',
|
|
74
|
+
educationalNote: 'Educational signal visualization only. Formant guides use smoothed spectral peak regions, not a validated LPC workflow, and must not be used for speaker identification.'
|
|
75
|
+
},
|
|
76
|
+
seo: [
|
|
77
|
+
{ type: 'title', text: 'How a Voice Spectrogram Turns Sound Into a Visible Landscape', level: 2 },
|
|
78
|
+
{ type: 'paragraph', html: 'A <strong>voice spectrogram</strong> transforms a recording into a map with time on the horizontal axis and frequency on the vertical axis. Stronger energy appears as brighter color. This makes sustained vowels, harmonics, silence, noise, and changing resonances easier to explore than they are in a waveform alone.' },
|
|
79
|
+
{ type: 'paragraph', html: 'The analyzer divides the signal into short overlapping frames, applies a Hamming window, and transforms each frame from amplitude over time into energy by frequency. A short frame preserves when a sound happened, while its frequency bins reveal where energy is concentrated. Because every spectrogram balances time resolution against frequency resolution, narrow transients and stable vowels can never both be represented with unlimited precision. The display should therefore be read as a measured view of the chosen settings, not as a perfect picture of the original pressure wave.' },
|
|
80
|
+
{ type: 'diagnostic', variant: 'info', title: 'Private Browser Processing', html: 'The selected recording is decoded into an in-memory audio buffer and analyzed locally. No upload is needed, so private classroom recordings, rehearsals, and personal voice samples remain on the device.' },
|
|
81
|
+
{ type: 'stats', columns: 3, items: [
|
|
82
|
+
{ value: 'Time', label: 'Read from left to right' },
|
|
83
|
+
{ value: 'Hz', label: 'Frequency position' },
|
|
84
|
+
{ value: 'Energy', label: 'Shown as luminous intensity' }
|
|
85
|
+
] },
|
|
86
|
+
{ type: 'title', text: 'Reading Formants Without Overstating the Result', level: 3 },
|
|
87
|
+
{ type: 'paragraph', html: 'Formants are resonant regions shaped by the vocal tract. F1 and F2 are often used in phonetics to discuss vowel height and place. This analyzer traces broad smoothed peaks in three frequency regions so beginners can connect visible bands with approximate F1, F2, and F3 behavior.' },
|
|
88
|
+
{ type: 'paragraph', html: 'Professional formant measurement normally uses a carefully configured linear predictive coding workflow, checks the tracking by eye, and adapts the formant ceiling to the speaker and vowel. Pitch harmonics, nasalization, room reflections, lossy compression, background noise, and weak recording levels can all pull a simple peak estimate away from the vocal tract resonance of interest. The guides here intentionally expose broad regions and average values for learning. If a guide jumps between bands or conflicts with the visible spectrum, treat that disagreement as a reason to inspect the recording and settings rather than as hidden evidence.' },
|
|
89
|
+
{ type: 'table', headers: ['Guide', 'Search region', 'Useful interpretation'], rows: [
|
|
90
|
+
['F1', '180 to 1000 Hz', 'A broad first resonance region often associated with vowel openness'],
|
|
91
|
+
['F2', '900 to 3000 Hz', 'A broad second resonance region often associated with front and back vowel position'],
|
|
92
|
+
['F3', '2000 to 4500 Hz', 'A higher resonance region affected by vocal tract shape and articulation']
|
|
93
|
+
] },
|
|
94
|
+
{ type: 'title', text: 'Why Analysis Settings Change the Picture', level: 3 },
|
|
95
|
+
{ type: 'comparative', columns: 2, items: [
|
|
96
|
+
{ title: 'Lower Ceiling', description: 'A 4 kHz ceiling gives more visual space to lower speech frequencies.', points: ['Useful for a close look at lower resonances', 'May exclude higher energy', 'Does not guarantee more accurate formants'] },
|
|
97
|
+
{ title: 'Higher Ceiling', description: 'A 6 or 8 kHz ceiling includes more upper spectrum detail.', highlight: true, points: ['Useful for brighter voices and broadband sounds', 'Shows frication and upper harmonics', 'Compresses lower bands vertically'] }
|
|
98
|
+
] },
|
|
99
|
+
{ type: 'title', text: 'A Responsible Two Sample Comparison', level: 3 },
|
|
100
|
+
{ type: 'paragraph', html: 'Comparing two plates is most useful when the recordings contain the same vowel, word, or short phrase and were made with similar microphones and environments. The displayed gaps are absolute differences between average peak positions. They do not model within speaker variability, between speaker variability, channel mismatch, speaking style, health, age, or the probability of competing explanations. For that reason the analyzer never converts a gap into a match percentage, identity badge, or forensic conclusion.' },
|
|
101
|
+
{ type: 'list', items: [
|
|
102
|
+
'<strong>Match the spoken material:</strong> repeated vowels or words are easier to compare than unrelated phrases.',
|
|
103
|
+
'<strong>Use similar recording conditions:</strong> microphones, compression, background noise, and distance can alter the spectrum.',
|
|
104
|
+
'<strong>Listen with the cursor:</strong> connect a visible event to the exact moment that produced it.',
|
|
105
|
+
'<strong>Avoid identity claims:</strong> a similar looking spectrogram does not prove that two recordings share a speaker.'
|
|
106
|
+
] },
|
|
107
|
+
{ type: 'summary', title: 'What This Analyzer Is For', items: [
|
|
108
|
+
'Generate an audio spectrogram locally from common browser decodable files.',
|
|
109
|
+
'Explore two samples in mirrored or parallel plates with synchronized playback.',
|
|
110
|
+
'Learn how spectral energy and approximate formant regions change across a recording.',
|
|
111
|
+
'Keep comparison descriptive and educational rather than forensic or biometric.'
|
|
112
|
+
] }
|
|
113
|
+
],
|
|
114
|
+
faq,
|
|
115
|
+
bibliography,
|
|
116
|
+
howTo,
|
|
117
|
+
schemas: [
|
|
118
|
+
{ '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: title, description, applicationCategory: 'MultimediaApplication', operatingSystem: 'Any' },
|
|
119
|
+
{ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) },
|
|
120
|
+
{ '@context': 'https://schema.org', '@type': 'HowTo', name: title, step: howTo.map((step) => ({ '@type': 'HowToStep', name: step.name, text: step.text })) }
|
|
121
|
+
]
|
|
122
|
+
};
|