@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.
Files changed (169) hide show
  1. package/package.json +2 -1
  2. package/src/category/index.ts +63 -59
  3. package/src/category/seo.astro +1 -1
  4. package/src/components/PreviewNavSidebar.astro +1 -1
  5. package/src/components/PreviewToolbar.astro +1 -1
  6. package/src/data.ts +1 -1
  7. package/src/entries.ts +74 -68
  8. package/src/env.d.ts +1 -1
  9. package/src/index.ts +33 -31
  10. package/src/pages/[locale]/[slug].astro +161 -159
  11. package/src/pages/[locale].astro +8 -5
  12. package/src/pages/index.astro +1 -1
  13. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  14. package/src/tests/diacritics_density.test.ts +1 -1
  15. package/src/tests/faq_count.test.ts +19 -19
  16. package/src/tests/i18n_coverage.test.ts +36 -36
  17. package/src/tests/inverted_punctuation.test.ts +1 -1
  18. package/src/tests/locale_completeness.test.ts +2 -2
  19. package/src/tests/mocks/astro_mock.js +1 -1
  20. package/src/tests/no_h1_in_components.test.ts +1 -1
  21. package/src/tests/script_density.test.ts +94 -94
  22. package/src/tests/seo_length.test.ts +46 -46
  23. package/src/tests/seo_parity.test.ts +2 -7
  24. package/src/tests/seo_translation_completeness.test.ts +68 -0
  25. package/src/tests/seo_wellformed_export.test.ts +65 -0
  26. package/src/tests/tool_validation.test.ts +2 -2
  27. package/src/tests/translation_copy.test.ts +23 -24
  28. package/src/tool/bloodstain-pattern-origin-analyzer/component.astro +9 -9
  29. package/src/tool/bloodstain-pattern-origin-analyzer/i18n/zh.ts +1 -1
  30. package/src/tool/dna-profile-match-probability-lab/bibliography.astro +14 -0
  31. package/src/tool/dna-profile-match-probability-lab/bibliography.ts +16 -0
  32. package/src/tool/dna-profile-match-probability-lab/component.astro +121 -0
  33. package/src/tool/dna-profile-match-probability-lab/controller.ts +159 -0
  34. package/src/tool/dna-profile-match-probability-lab/dna-profile-match-probability-lab.css +485 -0
  35. package/src/tool/dna-profile-match-probability-lab/dom-views.ts +93 -0
  36. package/src/tool/dna-profile-match-probability-lab/entry.ts +29 -0
  37. package/src/tool/dna-profile-match-probability-lab/evaluator.ts +19 -0
  38. package/src/tool/dna-profile-match-probability-lab/i18n/de.ts +43 -0
  39. package/src/tool/dna-profile-match-probability-lab/i18n/en.ts +196 -0
  40. package/src/tool/dna-profile-match-probability-lab/i18n/es.ts +40 -0
  41. package/src/tool/dna-profile-match-probability-lab/i18n/fr.ts +10 -0
  42. package/src/tool/dna-profile-match-probability-lab/i18n/id.ts +41 -0
  43. package/src/tool/dna-profile-match-probability-lab/i18n/it.ts +10 -0
  44. package/src/tool/dna-profile-match-probability-lab/i18n/ja.ts +40 -0
  45. package/src/tool/dna-profile-match-probability-lab/i18n/ko.ts +40 -0
  46. package/src/tool/dna-profile-match-probability-lab/i18n/nl.ts +29 -0
  47. package/src/tool/dna-profile-match-probability-lab/i18n/pl.ts +10 -0
  48. package/src/tool/dna-profile-match-probability-lab/i18n/pt.ts +41 -0
  49. package/src/tool/dna-profile-match-probability-lab/i18n/ru.ts +29 -0
  50. package/src/tool/dna-profile-match-probability-lab/i18n/sv.ts +41 -0
  51. package/src/tool/dna-profile-match-probability-lab/i18n/tr.ts +10 -0
  52. package/src/tool/dna-profile-match-probability-lab/i18n/zh.ts +29 -0
  53. package/src/tool/dna-profile-match-probability-lab/index.ts +11 -0
  54. package/src/tool/dna-profile-match-probability-lab/localize.ts +107 -0
  55. package/src/tool/dna-profile-match-probability-lab/logic.test.ts +56 -0
  56. package/src/tool/dna-profile-match-probability-lab/logic.ts +139 -0
  57. package/src/tool/dna-profile-match-probability-lab/seo.astro +15 -0
  58. package/src/tool/dna-profile-match-probability-lab/storage.ts +26 -0
  59. package/src/tool/dna-profile-match-probability-lab/ui.ts +3 -0
  60. package/src/tool/fire-pattern-origin-analyzer/component.astro +1 -1
  61. package/src/tool/fire-pattern-origin-analyzer/i18n/zh.ts +5 -1
  62. package/src/tool/fire-pattern-origin-analyzer/logic.ts +9 -2
  63. package/src/tool/fire-pattern-origin-analyzer/view-bindings.ts +8 -4
  64. package/src/tool/fire-pattern-origin-analyzer/view-model.ts +6 -2
  65. package/src/tool/forensic-age-estimator/bibliography.astro +1 -1
  66. package/src/tool/forensic-age-estimator/component.astro +1 -1
  67. package/src/tool/forensic-age-estimator/entry.ts +17 -17
  68. package/src/tool/forensic-age-estimator/i18n/de.ts +244 -244
  69. package/src/tool/forensic-age-estimator/i18n/en.ts +244 -244
  70. package/src/tool/forensic-age-estimator/i18n/es.ts +244 -244
  71. package/src/tool/forensic-age-estimator/i18n/fr.ts +244 -244
  72. package/src/tool/forensic-age-estimator/i18n/id.ts +244 -244
  73. package/src/tool/forensic-age-estimator/i18n/it.ts +244 -244
  74. package/src/tool/forensic-age-estimator/i18n/nl.ts +244 -244
  75. package/src/tool/forensic-age-estimator/i18n/pl.ts +244 -244
  76. package/src/tool/forensic-age-estimator/i18n/pt.ts +244 -244
  77. package/src/tool/forensic-age-estimator/i18n/ru.ts +244 -244
  78. package/src/tool/forensic-age-estimator/i18n/sv.ts +244 -244
  79. package/src/tool/forensic-age-estimator/i18n/tr.ts +244 -244
  80. package/src/tool/forensic-age-estimator/i18n/zh.ts +245 -245
  81. package/src/tool/forensic-age-estimator/index.ts +1 -1
  82. package/src/tool/forensic-age-estimator/seo.astro +1 -1
  83. package/src/tool/forensic-blood-test-simulator/component.astro +1 -1
  84. package/src/tool/forensic-fiber-comparison-microscope/component.astro +1 -1
  85. package/src/tool/forensic-fiber-comparison-microscope/render.ts +1 -1
  86. package/src/tool/forensic-fiber-comparison-microscope/view.ts +1 -1
  87. package/src/tool/forensic-fingerprint-minutiae-identifier/component.astro +2 -2
  88. package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/ja.ts +2 -1
  89. package/src/tool/forensic-fingerprint-minutiae-identifier/i18n/zh.ts +2 -0
  90. package/src/tool/forensic-fingerprint-minutiae-identifier/renderer.ts +9 -8
  91. package/src/tool/forensic-glass-becke-line-simulator/component.astro +1 -1
  92. package/src/tool/forensic-glass-becke-line-simulator/view.ts +8 -6
  93. package/src/tool/forensic-image-authenticity-analyzer/component.astro +1 -1
  94. package/src/tool/forensic-image-authenticity-analyzer/i18n/de.ts +105 -105
  95. package/src/tool/forensic-image-authenticity-analyzer/i18n/en.ts +105 -105
  96. package/src/tool/forensic-image-authenticity-analyzer/i18n/es.ts +105 -105
  97. package/src/tool/forensic-image-authenticity-analyzer/i18n/fr.ts +105 -105
  98. package/src/tool/forensic-image-authenticity-analyzer/i18n/id.ts +95 -90
  99. package/src/tool/forensic-image-authenticity-analyzer/i18n/it.ts +105 -105
  100. package/src/tool/forensic-image-authenticity-analyzer/i18n/ja.ts +7 -2
  101. package/src/tool/forensic-image-authenticity-analyzer/i18n/ko.ts +4 -1
  102. package/src/tool/forensic-image-authenticity-analyzer/i18n/nl.ts +95 -92
  103. package/src/tool/forensic-image-authenticity-analyzer/i18n/pl.ts +95 -92
  104. package/src/tool/forensic-image-authenticity-analyzer/i18n/pt.ts +105 -105
  105. package/src/tool/forensic-image-authenticity-analyzer/i18n/ru.ts +97 -92
  106. package/src/tool/forensic-image-authenticity-analyzer/i18n/sv.ts +97 -93
  107. package/src/tool/forensic-image-authenticity-analyzer/i18n/tr.ts +98 -93
  108. package/src/tool/forensic-image-authenticity-analyzer/i18n/zh.ts +11 -0
  109. package/src/tool/forensic-microcrystal-drug-simulator/component.astro +1 -1
  110. package/src/tool/forensic-sex-determinator/component.astro +3 -2
  111. package/src/tool/forensic-stature-estimator/component.astro +1 -1
  112. package/src/tool/forensic-stature-estimator/i18n/id.ts +4 -0
  113. package/src/tool/forensic-stature-estimator/i18n/nl.ts +4 -0
  114. package/src/tool/forensic-stature-estimator/i18n/ru.ts +4 -0
  115. package/src/tool/forensic-tlc-ink-simulator/component.astro +1 -1
  116. package/src/tool/forensic-tlc-ink-simulator/i18n/zh.ts +1 -0
  117. package/src/tool/forensic-toolmark-striation-matcher/component.astro +1 -1
  118. package/src/tool/forensic-toolmark-striation-matcher/logic.ts +1 -1
  119. package/src/tool/forensic-toolmark-striation-matcher/renderer.ts +7 -6
  120. package/src/tool/forensic-toolmark-striation-matcher/view.ts +19 -20
  121. package/src/tool/gsr-dispersion-calculator/component.astro +1 -1
  122. package/src/tool/gsr-dispersion-calculator/logic.ts +1 -1
  123. package/src/tool/time-of-death-algor-mortis-calculator/entry.ts +2 -0
  124. package/src/tool/voice-spectrogram-analyzer/audio-runtime.ts +75 -0
  125. package/src/tool/voice-spectrogram-analyzer/bibliography.astro +14 -0
  126. package/src/tool/voice-spectrogram-analyzer/bibliography.ts +16 -0
  127. package/src/tool/voice-spectrogram-analyzer/component.astro +116 -0
  128. package/src/tool/voice-spectrogram-analyzer/controller.ts +219 -0
  129. package/src/tool/voice-spectrogram-analyzer/dom-views.ts +204 -0
  130. package/src/tool/voice-spectrogram-analyzer/entry.ts +31 -0
  131. package/src/tool/voice-spectrogram-analyzer/evaluator.ts +13 -0
  132. package/src/tool/voice-spectrogram-analyzer/fft.ts +64 -0
  133. package/src/tool/voice-spectrogram-analyzer/i18n/de.ts +135 -0
  134. package/src/tool/voice-spectrogram-analyzer/i18n/en.ts +122 -0
  135. package/src/tool/voice-spectrogram-analyzer/i18n/es.ts +122 -0
  136. package/src/tool/voice-spectrogram-analyzer/i18n/fr.ts +135 -0
  137. package/src/tool/voice-spectrogram-analyzer/i18n/id.ts +136 -0
  138. package/src/tool/voice-spectrogram-analyzer/i18n/it.ts +135 -0
  139. package/src/tool/voice-spectrogram-analyzer/i18n/ja.ts +135 -0
  140. package/src/tool/voice-spectrogram-analyzer/i18n/ko.ts +135 -0
  141. package/src/tool/voice-spectrogram-analyzer/i18n/nl.ts +136 -0
  142. package/src/tool/voice-spectrogram-analyzer/i18n/pl.ts +136 -0
  143. package/src/tool/voice-spectrogram-analyzer/i18n/pt.ts +135 -0
  144. package/src/tool/voice-spectrogram-analyzer/i18n/ru.ts +136 -0
  145. package/src/tool/voice-spectrogram-analyzer/i18n/sv.ts +136 -0
  146. package/src/tool/voice-spectrogram-analyzer/i18n/tr.ts +136 -0
  147. package/src/tool/voice-spectrogram-analyzer/i18n/zh.ts +135 -0
  148. package/src/tool/voice-spectrogram-analyzer/index.ts +11 -0
  149. package/src/tool/voice-spectrogram-analyzer/logic.test.ts +46 -0
  150. package/src/tool/voice-spectrogram-analyzer/logic.ts +163 -0
  151. package/src/tool/voice-spectrogram-analyzer/seo.astro +15 -0
  152. package/src/tool/voice-spectrogram-analyzer/storage.ts +33 -0
  153. package/src/tool/voice-spectrogram-analyzer/ui.ts +49 -0
  154. package/src/tool/voice-spectrogram-analyzer/voice-spectrogram-analyzer.css +547 -0
  155. package/src/tool/widmark-alcohol-simulator/component.astro +1 -1
  156. package/src/tool/widmark-alcohol-simulator/i18n/de.ts +5 -0
  157. package/src/tool/widmark-alcohol-simulator/i18n/fr.ts +2 -1
  158. package/src/tool/widmark-alcohol-simulator/i18n/id.ts +1 -0
  159. package/src/tool/widmark-alcohol-simulator/i18n/it.ts +4 -1
  160. package/src/tool/widmark-alcohol-simulator/i18n/ja.ts +4 -1
  161. package/src/tool/widmark-alcohol-simulator/i18n/nl.ts +5 -1
  162. package/src/tool/widmark-alcohol-simulator/i18n/pl.ts +5 -1
  163. package/src/tool/widmark-alcohol-simulator/i18n/pt.ts +5 -1
  164. package/src/tool/widmark-alcohol-simulator/i18n/ru.ts +4 -1
  165. package/src/tool/widmark-alcohol-simulator/i18n/sv.ts +5 -1
  166. package/src/tool/widmark-alcohol-simulator/i18n/tr.ts +4 -1
  167. package/src/tool/widmark-alcohol-simulator/i18n/zh.ts +4 -1
  168. package/src/tools.ts +39 -35
  169. package/src/types.ts +4 -4
@@ -0,0 +1,136 @@
1
+ import { bibliography } from '../bibliography';
2
+ import type { VoiceSpectrogramLocaleContent } from '../entry';
3
+
4
+ const slug = "rost-spektrogram-analysator-online";
5
+ const title = "Rost Spektrogram Analysator Online";
6
+ const description = "Visualisera frekvens, tid, intensitet och formanter för två ljudfiler privat i din webbläsare.";
7
+
8
+ const howTo = [
9
+ {
10
+ "name": "Välj två prov",
11
+ "text": "Använd lokala filer eller syntetiska exempel."
12
+ },
13
+ {
14
+ "name": "Ställ in frekvenstak",
15
+ "text": "Välj 4, 6 eller 8 kHz efter rösttyp."
16
+ },
17
+ {
18
+ "name": "Läs spektrogrammet",
19
+ "text": "Granska tid, frekvens och formantlinjer."
20
+ },
21
+ {
22
+ "name": "Lyssna och jämför",
23
+ "text": "Jämför F1, F2 och F3 i utbildningssyfte."
24
+ }
25
+ ];
26
+
27
+ const faq = [
28
+ {
29
+ "question": "Vad visar ett röstspektrogram?",
30
+ "answer": "Ett spektrogram visar tid horisontellt, frekvens vertikalt och ljudstyrka genom ljusstyrka."
31
+ },
32
+ {
33
+ "question": "Laddas mina inspelningar upp?",
34
+ "answer": "Nej. All bearbetning sker lokalt i din webbläsare."
35
+ },
36
+ {
37
+ "question": "Vad betyder F1, F2 och F3?",
38
+ "answer": "De är uppskattningar av röstkanalens resonanser."
39
+ },
40
+ {
41
+ "question": "Kan verktyget identifiera en talare?",
42
+ "answer": "Nej. Visuell likhet utgör inte ett biometriskt bevis."
43
+ },
44
+ {
45
+ "question": "Varför ändras formanter med frekvenstaket?",
46
+ "answer": "Skalan påverkar hur spektraltoppar separeras."
47
+ }
48
+ ];
49
+
50
+ export const content: VoiceSpectrogramLocaleContent = {
51
+ slug,
52
+ title,
53
+ description,
54
+ ui: {
55
+ "privacyBadge": "Endast lokalt",
56
+ "privacyNote": "Dina inspelningar stannar på din enhet. Avkodning och analys sker i webbläsaren.",
57
+ "loadHeading": "Ladda två ljudfiler för analys",
58
+ "sampleALabel": "Prov A",
59
+ "sampleBLabel": "Prov B",
60
+ "chooseFileLabel": "Välj ljudfil",
61
+ "replaceFileLabel": "Byt ljudfil",
62
+ "dropHint": "Dra en ljudfil hit (max 25 MB). De första 20 sekunderna analyseras.",
63
+ "presetHint": "Testa direkt med två syntetiska vokalstudier.",
64
+ "presetWarmLabel": "Varm vokalstudie",
65
+ "presetBrightLabel": "Ljus vokalstudie",
66
+ "emptySampleLabel": "Väntar på ljud",
67
+ "readySampleLabel": "Spektralplatta klar",
68
+ "decodingSampleLabel": "Beräknar spektralplatta",
69
+ "errorSampleLabel": "Provet kunde inte analyseras",
70
+ "durationLabel": "Varaktighet",
71
+ "ceilingHeading": "Frekvenstak",
72
+ "ceilingFourLabel": "4 kHz",
73
+ "ceilingSixLabel": "6 kHz",
74
+ "ceilingEightLabel": "8 kHz",
75
+ "stageLabel": "Speglad röstspektrogram scen",
76
+ "mirrorViewLabel": "Speglade plattor",
77
+ "splitViewLabel": "Parallella plattor",
78
+ "playALabel": "Spela prov A",
79
+ "playBLabel": "Spela prov B",
80
+ "stopLabel": "Stoppa",
81
+ "timeAxisLabel": "Tid",
82
+ "frequencyAxisLabel": "Frekvens",
83
+ "intensityLegendLabel": "Ljusare färg indikerar högre energi",
84
+ "formantLegendLabel": "Uppskattade formantlinjer",
85
+ "sampleAEmptyCanvasLabel": "Ladda prov A för att visa spektrumet",
86
+ "sampleBEmptyCanvasLabel": "Ladda prov B för att visa spektrumet",
87
+ "comparisonHeading": "Analys av klangresonans",
88
+ "comparisonNote": "Genomsnittliga spektraltoppar. Skillnaderna är fysiska mätvärden, inte ett bevis på identitet.",
89
+ "formantOneLabel": "Första resonansområde (F1)",
90
+ "formantTwoLabel": "Andra resonansområde (F2)",
91
+ "formantThreeLabel": "Tredje resonansområde (F3)",
92
+ "averageLabel": "Genomsnitt",
93
+ "differenceLabel": "Skillnad",
94
+ "unavailableLabel": "Inte tillgänglig",
95
+ "statusEmptyLabel": "Ladda ett prov för att börja",
96
+ "statusSingleLabel": "En platta är klar",
97
+ "statusReadyLabel": "Båda spektralplattorna är klara",
98
+ "limitError": "Filen överskrider den lokala gränsen på 25 MB.",
99
+ "decodeError": "Webbläsaren kunde inte avkoda ljudformatet.",
100
+ "browserError": "Web Audio API är inte tillgängligt i denna webbläsare.",
101
+ "educationalNote": "Pedagogiskt verktyg. Formantlinjer ska inte användas för forensisk röstidentifiering."
102
+ },
103
+ seo: [
104
+ { type: 'title', text: "Hur ett röstspektrogram förvandlar ljud till en visuell bild", level: 2 },
105
+ { type: 'paragraph', html: "Ett <strong>röstspektrogram</strong> förvandlar en inspelning till en karta med tid på den horisontella axeln och frekvens på den vertikala axeln. Starkare energi visas med ljusare färg. Detta gör utdragna vokaler, övertoner, tystnad och resonanser lättare att observera än på en vanlig vågform. Visualiseringen underlättar spektralanalysen på ett mycket detaljerat och överskådligt sätt vid varje tillfälle." },
106
+ { type: 'paragraph', html: "Analysatorn delar upp signalen i korta överlappande avsnitt, tillämpar ett Hamming-fönster och beräknar energifördelningen per frekvens via FFT. Ett kort avsnitt bestämmer den exakta tidpunkten, medan frekvensupplösningen visar var energin koncentreras. På grund av osäkerhetsprincipen finns det alltid en avvägning mellan tids- och frekvensupplösning. Inställningarna styr den visuella skärpan och precisionen vid analysen. Detta gör det lättare att utvärdera röstens egenskaper i detalj." },
107
+
108
+ { type: 'diagnostic', variant: 'info', title: "Privat bearbetning i webbläsaren", html: "Visualisera frekvens, tid, intensitet och formanter för två ljudfiler privat i din webbläsare. Alla dina inspelningar stannar kvar på din enhet utan att skickas någonstans." },
109
+ { type: 'stats', columns: 3, items: [
110
+ { value: "Tid", label: "Läs från vänster till höger" },
111
+ { value: "Hz", label: "Frekvensposition" },
112
+ { value: "Energi", label: "Visas som ljusstyrka" }
113
+ ] },
114
+ { type: 'title', text: "Att tolka formanter på ett korrekt sätt", level: 3 },
115
+ { type: 'paragraph', html: "Formanter är resonansområden som formas av talapparaten. F1 och F2 används inom fonetik för att i detalj beskriva vokalhöjd och artikulationsställe. Denna analysator spårar utjämnade toppar i tre frekvensområden så att användaren kan koppla synliga band till F1, F2 och F3 på ett intuitivt och tydligt sätt." },
116
+ { type: 'paragraph', html: "Professionell formantmätning använder vanligtvis linjär prediktiv kodning (LPC) anpassad för talaren. Grundtonens övertoner, rumseko och bakgrundsbuller kan förskjuta enkla uppskattningar. Använd dessa linjer som pedagogiska riktlinjer och kontrollera alltid det underliggande spektrumet för visuell verifiering av frekvenserna." },
117
+ { type: 'table', headers: ['Guide', 'Region', 'Meaning'], rows: [["F1","180 till 1000 Hz","Första resonansområdet, kopplat till vokalöppning"],["F2","900 till 3000 Hz","Andra resonansområdet, kopplat till tungposition"],["F3","2000 till 4500 Hz","Högre resonansområde, påverkat av talapparaten"]] },
118
+ { type: 'title', text: "Frekvensinställningars påverkan på analysen", level: 3 },
119
+ { type: 'comparative', columns: 2, items: [
120
+ { title: "Lågt tak (4 kHz)", description: "Bättre vy för låga frekvenser", points: ["Användbart för vokaler", "Kan exkludera hög energi", "Garanterar inte högre precision"] },
121
+ { title: "Högt tak (6/8 kHz)", description: "Mer övre detaljer", highlight: true, points: ["För ljusa röster", "Visar frikativor", "Komprimerar nedre band"] }
122
+ ] },
123
+ { type: 'title', text: "En ansvarsfull jämförelse av två ljudprov", level: 3 },
124
+ { type: 'paragraph', html: "Att jämföra två plattor är mest användbart när båda inspelningarna innehåller samma vokal eller fras under liknande akustiska förhållanden. De visade skillnaderna är absoluta fysiska mätvärden mellan spektraltopparna. De utgör inte en procentuell likhet eller ett biometriskt bevis på identitet." },
125
+ { type: 'list', items: ["<strong>Jämför samma talade innehåll:</strong> upprepade vokaler eller ord är lättare att jämföra.","<strong>Använd liknande inspelningsförhållanden:</strong> mikrofon och rumsakustik påverkar spektrumet kraftigt.","<strong>Lyssna med markören:</strong> koppla visuella händelser till det exakta ljudögonblicket.","<strong>Undvik identitetspåståenden:</strong> ett liknande spektrogram bevisar inte talaridentitet."] },
126
+ { type: 'summary', title: "Sammanfattning av analysatorn", items: ["Generera ett ljudspektrogram lokalt från kompatibla filer.","Utforska två prov i speglade eller parallella plattor.","Lär dig hur spektral energi och formantområden förändras.","Behåll en beskrivande och pedagogisk ansats."] }
127
+ ],
128
+ faq,
129
+ bibliography,
130
+ howTo,
131
+ schemas: [
132
+ { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: title, description, applicationCategory: 'MultimediaApplication', operatingSystem: 'Any' },
133
+ { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) },
134
+ { '@context': 'https://schema.org', '@type': 'HowTo', name: title, step: howTo.map((step) => ({ '@type': 'HowToStep', name: step.name, text: step.text })) }
135
+ ]
136
+ };
@@ -0,0 +1,136 @@
1
+ import { bibliography } from '../bibliography';
2
+ import type { VoiceSpectrogramLocaleContent } from '../entry';
3
+
4
+ const slug = "ses-spektrogram-analizoru-online";
5
+ const title = "Ses Spektrogram Analizoru Online";
6
+ const description = "İki ses örneğinin frekans, zaman, yoğunluk ve tahmini formant kalıplarını tarayıcınızda gizlice görselleştirin.";
7
+
8
+ const howTo = [
9
+ {
10
+ "name": "İki örnek seçin",
11
+ "text": "Yerel ses dosyaları veya hazır sentetik örnekler kullanın."
12
+ },
13
+ {
14
+ "name": "Frekans tavanını ayarlayın",
15
+ "text": "Sese uygun olarak 4, 6 veya 8 kHz seçin."
16
+ },
17
+ {
18
+ "name": "Spektrogramı inceleyin",
19
+ "text": "Zaman, frekans ve formant çizgilerini gözlemleyin."
20
+ },
21
+ {
22
+ "name": "Dinleyin ve karşılaştırın",
23
+ "text": "F1, F2 ve F3 ortalamalarını eğitim amacıyla karşılaştırın."
24
+ }
25
+ ];
26
+
27
+ const faq = [
28
+ {
29
+ "question": "Ses spektrogramı ne gösterir?",
30
+ "answer": "Spektrogram zamanı yatay eksende, frekansı dikey eksende ve ses enerjisini parlaklıkla gösterir."
31
+ },
32
+ {
33
+ "question": "Ses kayıtlarım yükleniyor mu?",
34
+ "answer": "Hayır. Tüm işlemler tarayıcınızda yerel olarak yapılır."
35
+ },
36
+ {
37
+ "question": "F1, F2 ve F3 ne anlama gelir?",
38
+ "answer": "Ses yolunun rezonanslarının eğitici tahminleridir."
39
+ },
40
+ {
41
+ "question": "Bu araç konuşmacı kimliğini tespit edebilir mi?",
42
+ "answer": "Hayır. Görsel benzerlik adli kimlik kanıtı oluşturmaz."
43
+ },
44
+ {
45
+ "question": "Frekans tavanı değişince formantlar neden değişir?",
46
+ "answer": "Frekans ölçeği spektral tepelerin ayrışmasını etkiler."
47
+ }
48
+ ];
49
+
50
+ export const content: VoiceSpectrogramLocaleContent = {
51
+ slug,
52
+ title,
53
+ description,
54
+ ui: {
55
+ "privacyBadge": "Yalnızca yerel",
56
+ "privacyNote": "Ses kayıtlarınız cihazınızda kalır. Çözümleme ve analiz tarayıcı içinde çalışır.",
57
+ "loadHeading": "Analiz edilecek iki ses yükleyin",
58
+ "sampleALabel": "Örnek A",
59
+ "sampleBLabel": "Örnek B",
60
+ "chooseFileLabel": "Ses seç",
61
+ "replaceFileLabel": "Sesi değiştir",
62
+ "dropHint": "Buraya bir ses dosyası sürükleyin (maks. 25 MB). İlk 20 saniye analiz edilir.",
63
+ "presetHint": "Hazır sentetik ünlü çalışmalarıyla hemen test edin.",
64
+ "presetWarmLabel": "Sıcak ünlü çalışması",
65
+ "presetBrightLabel": "Parlak ünlü çalışması",
66
+ "emptySampleLabel": "Ses bekleniyor",
67
+ "readySampleLabel": "Spektral plaka hazır",
68
+ "decodingSampleLabel": "Spektral plaka işleniyor",
69
+ "errorSampleLabel": "Örnek analiz edilemedi",
70
+ "durationLabel": "Süre",
71
+ "ceilingHeading": "Frekans tavanı",
72
+ "ceilingFourLabel": "4 kHz",
73
+ "ceilingSixLabel": "6 kHz",
74
+ "ceilingEightLabel": "8 kHz",
75
+ "stageLabel": "Aynalı ses spektrogram sahnesi",
76
+ "mirrorViewLabel": "Aynalı plakalar",
77
+ "splitViewLabel": "Paralel plakalar",
78
+ "playALabel": "Örnek A çal",
79
+ "playBLabel": "Örnek B çal",
80
+ "stopLabel": "Durdur",
81
+ "timeAxisLabel": "Zaman",
82
+ "frequencyAxisLabel": "Frekans",
83
+ "intensityLegendLabel": "Daha parlak renk daha yüksek enerjiyi gösterir",
84
+ "formantLegendLabel": "Tahmini formant çizgileri",
85
+ "sampleAEmptyCanvasLabel": "Spektrumu görmek için örnek A yükleyin",
86
+ "sampleBEmptyCanvasLabel": "Spektrumu görmek için örnek B yükleyin",
87
+ "comparisonHeading": "Rezonans kalıpları analizi",
88
+ "comparisonNote": "Sesli bölümlerdeki ortalama spektral tepe noktaları. Farklar fiziksel ölçümlerdir, kimlik kanıtı değildir.",
89
+ "formantOneLabel": "Birinci rezonans bölgesi (F1)",
90
+ "formantTwoLabel": "İkinci rezonans bölgesi (F2)",
91
+ "formantThreeLabel": "Üçüncü rezonans bölgesi (F3)",
92
+ "averageLabel": "Ortalama",
93
+ "differenceLabel": "Fark",
94
+ "unavailableLabel": "Mevcut değil",
95
+ "statusEmptyLabel": "Başlamak için bir örnek yükleyin",
96
+ "statusSingleLabel": "Bir plaka hazır",
97
+ "statusReadyLabel": "Her iki spektral plaka hazır",
98
+ "limitError": "Dosya 25 MB yerel analiz sınırını aşıyor.",
99
+ "decodeError": "Tarayıcı bu ses biçimini çözemedi.",
100
+ "browserError": "Web Audio API bu tarayıcıda desteklenmiyor.",
101
+ "educationalNote": "Eğitim amaçlı görselleştirme aracı. Formant çizgileri adli konuşmacı kimlik tespiti için kullanılamaz."
102
+ },
103
+ seo: [
104
+ { type: 'title', text: "Ses spektrogramı sesi detaylı bir görsel haritaya nasıl dönüştürür", level: 2 },
105
+ { type: 'paragraph', html: "Bir <strong>ses spektrogramı</strong> kaydı, yatay eksende zaman ve dikey eksende frekans bulunan görsel bir haritaya dönüştürür. Daha güçlü enerji daha parlak bir renk olarak görünür. Bu durum, uzatılan ünlüleri, harmonikleri, sessizliği ve rezonansları sıradan bir dalga biçimine göre çok daha net gözlemlemeyi sağlar. Görselleştirme sinyal analizini kolaylaştırır ve ayrıntılı bir bakış sunarak ses incelemesini oldukça hızlandırır ve verimli kılar." },
106
+ { type: 'paragraph', html: "Analizör sinyali kısa örtüşen parçalara böler, Hamming penceresi uygular ve FFT kullanarak frekansa göre enerji dağılımını hesaplar. Kısa bir parça olayın tam zamanını belirlerken, frekans çözünürlüğü enerjinin nerede yoğunlaştığını gösterir. Belirsizlik ilkesi nedeniyle zaman ve frekans çözünürlüğü arasında her zaman bir denge vardır. Bu ayarlar görünüm netliğini belirler ve hassasiyeti artırır. Böylece ses özelliklerini daha detaylı değerlendirmek, incelemek ve karşılaştırmak mümkün hale gelir." },
107
+
108
+ { type: 'diagnostic', variant: 'info', title: "Tarayıcıda gizli işleme", html: "İki ses örneğinin frekans, zaman, yoğunluk ve tahmini formant kalıplarını tarayıcınızda gizlice görselleştirin. Tüm ses kayıtlarınız sunuculara yüklenmeden cihazınızda kalır." },
109
+ { type: 'stats', columns: 3, items: [
110
+ { value: "Zaman", label: "Soldan sağa okuyun" },
111
+ { value: "Hz", label: "Frekans konumu" },
112
+ { value: "Enerji", label: "Parlaklık olarak gösterilir" }
113
+ ] },
114
+ { type: 'title', text: "Formantları doğru yorumlamak", level: 3 },
115
+ { type: 'paragraph', html: "Formantlar ses yolu tarafından şekillendirilen rezonans bölgeleridir. F1 ve F2 fonetikte ünlü yüksekliğini ve eklemleme yerini ayrıntılı olarak açıklamak için kullanılır. Bu analizör, kullanıcıların görünür bantları F1, F2 ve F3 davranışıyla sezgisel ve doğru bir şekilde ilişkilendirebilmesi için üç frekans bölgesindeki düzeltilmiş tepeleri takip eder." },
116
+ { type: 'paragraph', html: "Profesyonel formant ölçümü genellikle konuşmacıya uyarlanmış Doğrusal Öngörümlü Kodlama (LPC) kullanır. Ana ses harmonikleri, yankı ve arka plan gürültüsü basit tahminleri kaydırabilir. Bu kılavuzları eğitici bir referans olarak kullanın ve her zaman arka plandaki görsel spektrumu doğrulamak için kontrol edin." },
117
+ { type: 'table', headers: ['Guide', 'Region', 'Meaning'], rows: [["F1","180 - 1000 Hz","Birinci rezonans bölgesi, ünlü açıklığı ile ilişkili"],["F2","900 - 3000 Hz","İkinci rezonans bölgesi, dil konumu ile ilişkili"],["F3","2000 - 4500 Hz","Üst rezonans bölgesi, ses yolu geometrisinden etkilenir"]] },
118
+ { type: 'title', text: "Frekans ayarlarının analize etkisi", level: 3 },
119
+ { type: 'comparative', columns: 2, items: [
120
+ { title: "Düşük tavan (4 kHz)", description: "Alt frekanslar için daha iyi görünüm", points: ["Ünlüler için kullanışlı", "Yüksek enerjiyi dışarıda bırakabilir", "Daha yüksek doğruluk garanti etmez"] },
121
+ { title: "Yüksek tavan (6/8 kHz)", description: "Daha fazla üst detay", highlight: true, points: ["Parlak sesler için", "Sürtünmeli sesleri gösterir", "Alt bantları sıkıştırır"] }
122
+ ] },
123
+ { type: 'title', text: "İki ses örneğini sorumlulukla karşılaştırmak", level: 3 },
124
+ { type: 'paragraph', html: "İki plakayı karşılaştırmak, her iki kayıt benzer akustik koşullarda aynı ünlüyü veya cümleyi içerdiğinde en faydalı olanıdır. Görüntülenen farklar spektral tepeler arasındaki mutlak fiziksel ölçümlerdir. Bir benzerlik yüzdesi veya biyometrik kimlik kanıtı oluşturmazlar." },
125
+ { type: 'list', items: ["<strong>Aynı konuşma içeriğini karşılaştırın:</strong> tekrarlanan ünlüler veya kelimeler daha kolay karşılaştırılır.","<strong>Kayıt koşullarını eşitleyin:</strong> mikrofon ve oda akustiği spektrumu güçlü bir şekilde etkiler.","<strong>İmleçle dinleyin:</strong> görsel olayları tam ses anıyla ilişkilendirin.","<strong>Kimlik iddiasında bulunmayın:</strong> benzer görünen bir spektrogram konuşmacı kimliğini kanıtlamaz."] },
126
+ { type: 'summary', title: "Analizör özeti", items: ["Uyumlu dosyalardan yerel olarak bir ses spektrogramı oluşturun.","İki örneği aynalı veya paralel plakalarda keşfedin.","Spektral enerjinin ve formant bölgelerinin nasıl değiştiğini öğrenin.","Açıklayıcı ve eğitici bir yaklaşım sürdürün."] }
127
+ ],
128
+ faq,
129
+ bibliography,
130
+ howTo,
131
+ schemas: [
132
+ { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: title, description, applicationCategory: 'MultimediaApplication', operatingSystem: 'Any' },
133
+ { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) },
134
+ { '@context': 'https://schema.org', '@type': 'HowTo', name: title, step: howTo.map((step) => ({ '@type': 'HowToStep', name: step.name, text: step.text })) }
135
+ ]
136
+ };
@@ -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 }} />}