nuxt-module-essentia 1.0.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/README.md +54 -0
- package/dist/module.d.ts +5 -0
- package/dist/module.js +31 -0
- package/dist/runtime/composables/useAudioAnalizer.d.ts +44 -0
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/utils/config.d.ts +1 -0
- package/package.json +70 -0
- package/src/runtime/composables/useAudioAnalizer.ts +379 -0
- package/src/runtime/essentia-wasm.web.js +40 -0
- package/src/runtime/essentia.js-core.js +1 -0
- package/src/runtime/index.ts +1 -0
- package/src/runtime/models/LICENSE +1367 -0
- package/src/runtime/models/danceability-msd-musicnn-1/tfjs/group1-shard1of1.bin +0 -0
- package/src/runtime/models/danceability-msd-musicnn-1/tfjs/model.json +1 -0
- package/src/runtime/models/emomusic-msd-musicnn-1/tfjs/group1-shard1of1.bin +0 -0
- package/src/runtime/models/emomusic-msd-musicnn-1/tfjs/model.json +1 -0
- package/src/runtime/models/mood_aggressive-msd-musicnn-1/tfjs/group1-shard1of1.bin +0 -0
- package/src/runtime/models/mood_aggressive-msd-musicnn-1/tfjs/model.json +1 -0
- package/src/runtime/models/mood_happy-msd-musicnn-1/tfjs/group1-shard1of1.bin +0 -0
- package/src/runtime/models/mood_happy-msd-musicnn-1/tfjs/model.json +1 -0
- package/src/runtime/models/mood_relaxed-msd-musicnn-1/tfjs/group1-shard1of1.bin +0 -0
- package/src/runtime/models/mood_relaxed-msd-musicnn-1/tfjs/model.json +1 -0
- package/src/runtime/models/mood_sad-msd-musicnn-1/tfjs/group1-shard1of1.bin +0 -0
- package/src/runtime/models/mood_sad-msd-musicnn-1/tfjs/model.json +1 -0
- package/src/runtime/models/msd-musicnn-1/group1-shard1of1.bin +0 -0
- package/src/runtime/models/msd-musicnn-1/model.json +1 -0
- package/src/runtime/utils/config.ts +14 -0
- package/src/runtime/workers/featureExtraction.js +89 -0
- package/src/runtime/workers/inference.js +135 -0
- package/src/runtime/workers/lib/essentia-wasm.module.js +33 -0
- package/src/runtime/workers/lib/essentia.js-model.umd.js +641 -0
- package/src/runtime/workers/lib/tf-backend-wasm-3.5.0.js +5115 -0
- package/src/runtime/workers/lib/tf.min.3.5.0.js +18 -0
- package/src/runtime/workers/tfjs-backend-wasm-simd.wasm +0 -0
- package/src/runtime/workers/tfjs-backend-wasm-threaded-simd.wasm +0 -0
- package/src/runtime/workers/tfjs-backend-wasm.wasm +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# nuxt-module-essentia
|
|
2
|
+
|
|
3
|
+
Nuxt модуль для интеграции Essentia.js WASM библиотеки анализа аудио.
|
|
4
|
+
Работает на Nuxt 3 и 4
|
|
5
|
+
|
|
6
|
+
## Установка
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install nuxt-module-essentia
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
### Подключение модуля
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
// nuxt.config.ts
|
|
16
|
+
export default defineNuxtConfig({
|
|
17
|
+
modules: ["nuxt-module-essentia"],
|
|
18
|
+
|
|
19
|
+
essentia: {
|
|
20
|
+
publicAssetsPath: "/essentia/", // опционально
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Использование композабла
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
// Автоматически доступен в компонентах
|
|
29
|
+
const {
|
|
30
|
+
getKeyMoodAndBpm,
|
|
31
|
+
keyBpmResults,
|
|
32
|
+
moodResults,
|
|
33
|
+
resetMoodResults,
|
|
34
|
+
essentia,
|
|
35
|
+
essentiaAnalysis,
|
|
36
|
+
featureExtractionWorker
|
|
37
|
+
} = useAudioAnalizer();
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Разработка
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Сборка модуля
|
|
44
|
+
npm run build
|
|
45
|
+
|
|
46
|
+
# Режим разработки с watch
|
|
47
|
+
npm run dev
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Структура
|
|
51
|
+
|
|
52
|
+
- `src/module.ts` - основной файл модуля
|
|
53
|
+
- `src/runtime/` - runtime файлы (копируются в public)
|
|
54
|
+
- `src/runtime/composables/` - композаблы (автоимпорт)
|
package/dist/module.d.ts
ADDED
package/dist/module.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { defineNuxtModule as a, createResolver as r, addTemplate as l, addImportsDir as c } from "@nuxt/kit";
|
|
2
|
+
const u = a({
|
|
3
|
+
meta: {
|
|
4
|
+
name: "nuxt-module-essentia",
|
|
5
|
+
configKey: "essentia",
|
|
6
|
+
compatibility: {
|
|
7
|
+
nuxt: "^3.0.0 || ^4.0.0"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
defaults: {
|
|
11
|
+
publicAssetsPath: "/essentia/"
|
|
12
|
+
},
|
|
13
|
+
setup(e, s) {
|
|
14
|
+
var i;
|
|
15
|
+
const t = r(import.meta.url), o = t.resolve("../src/runtime"), n = t.resolve("../src/runtime/composables");
|
|
16
|
+
(i = s.options.nitro).publicAssets || (i.publicAssets = []), s.options.nitro.publicAssets.push({
|
|
17
|
+
dir: o,
|
|
18
|
+
baseURL: e.publicAssetsPath,
|
|
19
|
+
maxAge: 60 * 60 * 24 * 365
|
|
20
|
+
// 1 год
|
|
21
|
+
}), l({
|
|
22
|
+
filename: "essentia-config.mjs",
|
|
23
|
+
getContents: () => `export const essentiaConfig = ${JSON.stringify({
|
|
24
|
+
publicAssetsPath: e.publicAssetsPath
|
|
25
|
+
})}`
|
|
26
|
+
}), c(n);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
export {
|
|
30
|
+
u as default
|
|
31
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type Ref } from "vue";
|
|
2
|
+
export declare const useAudioAnalizer: () => {
|
|
3
|
+
getKeyMoodAndBpm: () => void;
|
|
4
|
+
keyBpmResults: Ref<{}, {}>;
|
|
5
|
+
moodResults: Ref<{}, {}>;
|
|
6
|
+
resetMoodResults: () => void;
|
|
7
|
+
essentia?: undefined;
|
|
8
|
+
essentiaAnalysis?: undefined;
|
|
9
|
+
featureExtractionWorker?: undefined;
|
|
10
|
+
} | {
|
|
11
|
+
getKeyMoodAndBpm: (file: File) => Promise<void>;
|
|
12
|
+
keyBpmResults: Ref<{
|
|
13
|
+
key: string;
|
|
14
|
+
bpm: number;
|
|
15
|
+
scale: string;
|
|
16
|
+
} | null, {
|
|
17
|
+
key: string;
|
|
18
|
+
bpm: number;
|
|
19
|
+
scale: string;
|
|
20
|
+
} | null>;
|
|
21
|
+
moodResults: Ref<{
|
|
22
|
+
color: string;
|
|
23
|
+
icon: string;
|
|
24
|
+
title: string;
|
|
25
|
+
key: string;
|
|
26
|
+
value: number;
|
|
27
|
+
}[], {
|
|
28
|
+
color: string;
|
|
29
|
+
icon: string;
|
|
30
|
+
title: string;
|
|
31
|
+
key: string;
|
|
32
|
+
value: number;
|
|
33
|
+
}[] | {
|
|
34
|
+
color: string;
|
|
35
|
+
icon: string;
|
|
36
|
+
title: string;
|
|
37
|
+
key: string;
|
|
38
|
+
value: number;
|
|
39
|
+
}[]>;
|
|
40
|
+
resetMoodResults: () => void;
|
|
41
|
+
essentia: any;
|
|
42
|
+
essentiaAnalysis: undefined;
|
|
43
|
+
featureExtractionWorker: any;
|
|
44
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useAudioAnalizer } from "./composables/useAudioAnalizer";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getEssentiaConfig(): Promise<any>;
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nuxt-module-essentia",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Nuxt module for Essentia WASM integration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/module.js",
|
|
7
|
+
"types": "./dist/module.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/module.js",
|
|
11
|
+
"types": "./dist/module.d.ts"
|
|
12
|
+
},
|
|
13
|
+
"./runtime": {
|
|
14
|
+
"import": "./src/runtime/index.ts",
|
|
15
|
+
"types": "./src/runtime/index.ts"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src/runtime"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "vite build && tsc --emitDeclarationOnly",
|
|
24
|
+
"dev": "vite build --watch",
|
|
25
|
+
"prepublishOnly": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@nuxt/kit": "^3.0.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@nuxt/schema": "^3.0.0",
|
|
32
|
+
"nuxt": "^3.0.0",
|
|
33
|
+
"typescript": "^5.0.0",
|
|
34
|
+
"vite": "^5.0.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"nuxt": "^3.0.0"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/nikitakashin/nuxt-module-essentia.git"
|
|
42
|
+
},
|
|
43
|
+
"author": {
|
|
44
|
+
"name": "Kashin Nikita",
|
|
45
|
+
"url": "https://github.com/nikitakashin/",
|
|
46
|
+
"email": "dev@nkashin.ru"
|
|
47
|
+
},
|
|
48
|
+
"published": "11.2025",
|
|
49
|
+
"keywords": [
|
|
50
|
+
"nuxt",
|
|
51
|
+
"nuxt-module",
|
|
52
|
+
"essentia",
|
|
53
|
+
"essentia-wasm",
|
|
54
|
+
"audio-analysis",
|
|
55
|
+
"music-information-retrieval",
|
|
56
|
+
"wasm",
|
|
57
|
+
"web-audio",
|
|
58
|
+
"audio-processing",
|
|
59
|
+
"machine-learning",
|
|
60
|
+
"signal-processing",
|
|
61
|
+
"feature-extraction",
|
|
62
|
+
"nuxt3",
|
|
63
|
+
"nuxt4"
|
|
64
|
+
],
|
|
65
|
+
"license": "ISC",
|
|
66
|
+
"bugs": {
|
|
67
|
+
"url": "https://github.com/nikitakashin/nuxt-module-essentia/issues"
|
|
68
|
+
},
|
|
69
|
+
"homepage": "https://github.com/nikitakashin/nuxt-module-essentia"
|
|
70
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { ref, type Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
export const useAudioAnalizer = () => {
|
|
4
|
+
if (!import.meta.client) {
|
|
5
|
+
return {
|
|
6
|
+
getKeyMoodAndBpm: () => {},
|
|
7
|
+
keyBpmResults: ref({}),
|
|
8
|
+
moodResults: ref({}),
|
|
9
|
+
resetMoodResults: () => {},
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_MOOD_VALUE = [
|
|
14
|
+
{
|
|
15
|
+
color: "light-blue-lighten-2",
|
|
16
|
+
icon: "💃",
|
|
17
|
+
title: "Танцевальный",
|
|
18
|
+
key: "danceability",
|
|
19
|
+
value: 0,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
color: "light-blue-lighten-1",
|
|
23
|
+
icon: "😊",
|
|
24
|
+
title: "Радостный",
|
|
25
|
+
key: "mood_happy",
|
|
26
|
+
value: 0,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
color: "light-blue-darken-1",
|
|
30
|
+
icon: "😢",
|
|
31
|
+
title: "Грустный",
|
|
32
|
+
key: "mood_sad",
|
|
33
|
+
value: 0,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
color: "light-blue-darken-2",
|
|
37
|
+
icon: "😌",
|
|
38
|
+
title: "Расслабляющий",
|
|
39
|
+
key: "mood_relaxed",
|
|
40
|
+
value: 0,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
color: "light-blue-darken-3",
|
|
44
|
+
icon: "😤",
|
|
45
|
+
title: "Агрессивный",
|
|
46
|
+
key: "mood_aggressive",
|
|
47
|
+
value: 0,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const keyBpmResults: Ref<{ key: string; bpm: number; scale: string } | null> = ref(null);
|
|
52
|
+
|
|
53
|
+
const moodResults = ref(DEFAULT_MOOD_VALUE);
|
|
54
|
+
|
|
55
|
+
let audioCtx: AudioContext;
|
|
56
|
+
|
|
57
|
+
if (import.meta.client) {
|
|
58
|
+
// @ts-ignore
|
|
59
|
+
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const KEEP_PERCENTAGE = 0.15; // keep only 15% of audio file
|
|
63
|
+
let essentia: any = null;
|
|
64
|
+
// @ts-ignore
|
|
65
|
+
let essentiaAnalysis;
|
|
66
|
+
let featureExtractionWorker: any = null;
|
|
67
|
+
const modelNames = ["mood_happy", "mood_sad", "mood_relaxed", "mood_aggressive", "danceability"];
|
|
68
|
+
|
|
69
|
+
let basePath = "/essentia/";
|
|
70
|
+
let inferenceWorker: Worker;
|
|
71
|
+
let inferenceStartTime = 0;
|
|
72
|
+
|
|
73
|
+
if (import.meta.client) {
|
|
74
|
+
// Загружаем конфиг асинхронно
|
|
75
|
+
// @ts-ignore
|
|
76
|
+
import("#build/essentia-config.mjs")
|
|
77
|
+
.then((module: any) => {
|
|
78
|
+
basePath = module.essentiaConfig.publicAssetsPath;
|
|
79
|
+
})
|
|
80
|
+
.catch(() => {
|
|
81
|
+
basePath = "/essentia/"; // fallback
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
createInferenceWorker();
|
|
85
|
+
createFeatureExtractionWorker();
|
|
86
|
+
}
|
|
87
|
+
// @ts-ignore
|
|
88
|
+
if (import.meta.client) {
|
|
89
|
+
// Загружаем essentia через глобальный window объект
|
|
90
|
+
const script = document.createElement("script");
|
|
91
|
+
script.src = `${basePath}essentia-wasm.web.js`;
|
|
92
|
+
script.onload = () => {
|
|
93
|
+
// @ts-ignore
|
|
94
|
+
window.EssentiaWASM().then((wasmModule: any) => {
|
|
95
|
+
essentia = new wasmModule.EssentiaJS(false);
|
|
96
|
+
essentia.arrayToVector = wasmModule.arrayToVector;
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
document.head.appendChild(script);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function createInferenceWorker() {
|
|
103
|
+
inferenceWorker = new Worker(`${basePath}workers/inference.js`);
|
|
104
|
+
inferenceWorker.onmessage = function listenToWorker(msg) {
|
|
105
|
+
if (msg.data.predictions) {
|
|
106
|
+
const preds = msg.data.predictions;
|
|
107
|
+
|
|
108
|
+
moodResults.value.forEach((mood) => {
|
|
109
|
+
mood.value = Math.ceil(preds[mood.key] * 100);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function createFeatureExtractionWorker() {
|
|
116
|
+
featureExtractionWorker = new Worker(`${basePath}workers/featureExtraction.js`);
|
|
117
|
+
featureExtractionWorker.postMessage({
|
|
118
|
+
init: true,
|
|
119
|
+
});
|
|
120
|
+
featureExtractionWorker.onmessage =
|
|
121
|
+
// @ts-ignore
|
|
122
|
+
function listenToFeatureExtractionWorker(msg) {
|
|
123
|
+
// feed to models
|
|
124
|
+
if (msg.data.embeddings) {
|
|
125
|
+
inferenceStartTime = Date.now();
|
|
126
|
+
// send features off to each of the models
|
|
127
|
+
inferenceWorker.postMessage({
|
|
128
|
+
embeddings: msg.data.embeddings,
|
|
129
|
+
});
|
|
130
|
+
// msg.data.embeddings = null;
|
|
131
|
+
}
|
|
132
|
+
// free worker resource until next audio is uploaded
|
|
133
|
+
// featureExtractionWorker.terminate();
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function monomix(buffer: AudioBuffer) {
|
|
138
|
+
// downmix to mono
|
|
139
|
+
let monoAudio;
|
|
140
|
+
if (buffer.numberOfChannels > 1) {
|
|
141
|
+
const leftCh = buffer.getChannelData(0);
|
|
142
|
+
const rightCh = buffer.getChannelData(1);
|
|
143
|
+
// @ts-ignore
|
|
144
|
+
monoAudio = leftCh.map((sample, i) => 0.5 * (sample + rightCh[i]));
|
|
145
|
+
} else {
|
|
146
|
+
monoAudio = buffer.getChannelData(0);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return monoAudio;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function shortenAudio(audioIn: Float32Array, keepRatio = 0.5, trim = false) {
|
|
153
|
+
/*
|
|
154
|
+
keepRatio applied after discarding start and end (if trim == true)
|
|
155
|
+
*/
|
|
156
|
+
if (keepRatio < 0.15) {
|
|
157
|
+
keepRatio = 0.15; // must keep at least 15% of the file
|
|
158
|
+
} else if (keepRatio > 0.66) {
|
|
159
|
+
keepRatio = 0.66; // will keep at most 2/3 of the file
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (trim) {
|
|
163
|
+
const discardSamples = Math.floor(0.1 * audioIn.length); // discard 10% on beginning and end
|
|
164
|
+
audioIn = audioIn.subarray(discardSamples, audioIn.length - discardSamples); // create new view of buffer without beginning and end
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const ratioSampleLength = Math.ceil(audioIn.length * keepRatio);
|
|
168
|
+
const patchSampleLength = 187 * 256; // cut into patchSize chunks so there's no weird jumps in audio
|
|
169
|
+
const numPatchesToKeep = Math.ceil(ratioSampleLength / patchSampleLength);
|
|
170
|
+
|
|
171
|
+
// space patchesToKeep evenly
|
|
172
|
+
const skipSize = Math.floor((audioIn.length - ratioSampleLength) / (numPatchesToKeep - 1));
|
|
173
|
+
|
|
174
|
+
let audioOut = [];
|
|
175
|
+
let startIndex = 0;
|
|
176
|
+
for (let i = 0; i < numPatchesToKeep; i++) {
|
|
177
|
+
let endIndex = startIndex + patchSampleLength;
|
|
178
|
+
let chunk = audioIn.slice(startIndex, endIndex);
|
|
179
|
+
audioOut.push(...chunk);
|
|
180
|
+
startIndex = endIndex + skipSize; // discard even space
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return Float32Array.from(audioOut);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function downsampleArray(audioIn: Float32Array, sampleRateIn: number, sampleRateOut: number) {
|
|
187
|
+
if (sampleRateOut === sampleRateIn) {
|
|
188
|
+
return audioIn;
|
|
189
|
+
}
|
|
190
|
+
let sampleRateRatio = sampleRateIn / sampleRateOut;
|
|
191
|
+
let newLength = Math.round(audioIn.length / sampleRateRatio);
|
|
192
|
+
let result = new Float32Array(newLength);
|
|
193
|
+
let offsetResult = 0;
|
|
194
|
+
let offsetAudioIn = 0;
|
|
195
|
+
|
|
196
|
+
while (offsetResult < result.length) {
|
|
197
|
+
let nextOffsetAudioIn = Math.round((offsetResult + 1) * sampleRateRatio);
|
|
198
|
+
let accum = 0,
|
|
199
|
+
count = 0;
|
|
200
|
+
for (let i = offsetAudioIn; i < nextOffsetAudioIn && i < audioIn.length; i++) {
|
|
201
|
+
// @ts-ignore
|
|
202
|
+
accum += audioIn[i];
|
|
203
|
+
count++;
|
|
204
|
+
}
|
|
205
|
+
result[offsetResult] = accum / count;
|
|
206
|
+
offsetResult++;
|
|
207
|
+
offsetAudioIn = nextOffsetAudioIn;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function median(arr: number[]) {
|
|
214
|
+
if (arr.length === 0) return 0;
|
|
215
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
216
|
+
const mid = Math.floor(sorted.length / 2);
|
|
217
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function estimateTuningFrequency(vectorSignal: any, sampleRate = 16000) {
|
|
221
|
+
// Параметры для pitch-экстракции
|
|
222
|
+
const frameSize = 2048;
|
|
223
|
+
const hopSize = 512;
|
|
224
|
+
const minFreq = 80; // игнорируем басы и шум
|
|
225
|
+
const maxFreq = 1500; // выше — уже не вокал/мелодия
|
|
226
|
+
const confidenceThreshold = 0.7;
|
|
227
|
+
const silenceThreshold = 0.001; // чувствительность к тишине
|
|
228
|
+
|
|
229
|
+
// ✅ Теперь 7 аргументов!
|
|
230
|
+
const pitchResult = essentia.PitchYinFFT(
|
|
231
|
+
vectorSignal,
|
|
232
|
+
sampleRate,
|
|
233
|
+
frameSize,
|
|
234
|
+
hopSize,
|
|
235
|
+
minFreq,
|
|
236
|
+
maxFreq,
|
|
237
|
+
silenceThreshold
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const centsDeviations = [];
|
|
241
|
+
|
|
242
|
+
for (let i = 0; i < pitchResult.pitch.length; i++) {
|
|
243
|
+
const freq = pitchResult.pitch[i];
|
|
244
|
+
const conf = pitchResult.pitchConfidence[i];
|
|
245
|
+
|
|
246
|
+
// Фильтруем ненадёжные и нерелевантные частоты
|
|
247
|
+
if (freq >= minFreq && freq <= maxFreq && conf >= confidenceThreshold) {
|
|
248
|
+
// MIDI note относительно A4=440 (MIDI 69)
|
|
249
|
+
const midiNote = 69 + 12 * Math.log2(freq / 440);
|
|
250
|
+
const roundedMidi = Math.round(midiNote);
|
|
251
|
+
const cents = (midiNote - roundedMidi) * 100; // отклонение в центах
|
|
252
|
+
|
|
253
|
+
// Ограничиваем выбросы (иногда pitch скачет)
|
|
254
|
+
if (Math.abs(cents) < 50) {
|
|
255
|
+
centsDeviations.push(cents);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Если мало данных — возвращаем 440 по умолчанию
|
|
261
|
+
if (centsDeviations.length < 10) {
|
|
262
|
+
console.warn("Недостаточно надёжных данных для оценки строя. Используется A=440.");
|
|
263
|
+
return 440;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const medianCents = median(centsDeviations);
|
|
267
|
+
const estimatedA = 440 * Math.pow(2, medianCents / 1200);
|
|
268
|
+
|
|
269
|
+
// Ограничиваем разумные пределы (обычно A=432–445)
|
|
270
|
+
return Math.max(430, Math.min(450, estimatedA));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function preprocess(audioBuffer: AudioBuffer) {
|
|
274
|
+
if (audioBuffer instanceof AudioBuffer) {
|
|
275
|
+
const mono = monomix(audioBuffer);
|
|
276
|
+
// downmix to mono, and downsample to 16kHz sr for essentia tensorflow models
|
|
277
|
+
return downsampleArray(mono, audioBuffer.sampleRate, 16000);
|
|
278
|
+
} else {
|
|
279
|
+
throw new TypeError("Input to audio preprocessing is not of type AudioBuffer");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function computeKeyBPM(audioSignal: Float32Array) {
|
|
284
|
+
let vectorSignal = essentia.arrayToVector(audioSignal);
|
|
285
|
+
|
|
286
|
+
// const estimatedA = estimateTuningFrequency(vectorSignal, 16000);
|
|
287
|
+
// console.log("Оценка строя:", estimatedA.toFixed(2), "Гц"); // например: 439.12
|
|
288
|
+
|
|
289
|
+
const keyData = essentia.KeyExtractor(
|
|
290
|
+
vectorSignal,
|
|
291
|
+
true,
|
|
292
|
+
4096,
|
|
293
|
+
4096,
|
|
294
|
+
12,
|
|
295
|
+
3500,
|
|
296
|
+
60,
|
|
297
|
+
25,
|
|
298
|
+
0.2,
|
|
299
|
+
"bgate",
|
|
300
|
+
16000,
|
|
301
|
+
0.0001,
|
|
302
|
+
440,
|
|
303
|
+
"cosine",
|
|
304
|
+
"hann"
|
|
305
|
+
);
|
|
306
|
+
const bpm = essentia.PercivalBpmEstimator(
|
|
307
|
+
vectorSignal,
|
|
308
|
+
1024,
|
|
309
|
+
2048,
|
|
310
|
+
128,
|
|
311
|
+
128,
|
|
312
|
+
210,
|
|
313
|
+
50,
|
|
314
|
+
16000
|
|
315
|
+
).bpm;
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
keyData: keyData,
|
|
319
|
+
bpm: bpm,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function processFile(arrayBuffer: ArrayBuffer) {
|
|
324
|
+
audioCtx.resume().then(() => {
|
|
325
|
+
audioCtx.decodeAudioData(arrayBuffer).then(async function handleDecodedAudio(audioBuffer) {
|
|
326
|
+
const prepocessedAudio = preprocess(audioBuffer);
|
|
327
|
+
await audioCtx.suspend();
|
|
328
|
+
|
|
329
|
+
if (essentia) {
|
|
330
|
+
essentiaAnalysis = computeKeyBPM(prepocessedAudio);
|
|
331
|
+
|
|
332
|
+
keyBpmResults.value = {
|
|
333
|
+
key: essentiaAnalysis.keyData.key,
|
|
334
|
+
scale: essentiaAnalysis.keyData.scale,
|
|
335
|
+
bpm: (essentiaAnalysis.bpm <= 69
|
|
336
|
+
? essentiaAnalysis.bpm * 2
|
|
337
|
+
: essentiaAnalysis.bpm
|
|
338
|
+
).toFixed(2),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// reduce amount of audio to analyse
|
|
343
|
+
let audioData = shortenAudio(prepocessedAudio, KEEP_PERCENTAGE, true); // <-- TRIMMED start/end
|
|
344
|
+
|
|
345
|
+
// send for feature extraction
|
|
346
|
+
featureExtractionWorker.postMessage(
|
|
347
|
+
{
|
|
348
|
+
audio: audioData.buffer,
|
|
349
|
+
},
|
|
350
|
+
[audioData.buffer]
|
|
351
|
+
);
|
|
352
|
+
// @ts-ignore
|
|
353
|
+
audioData = null;
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const getKeyMoodAndBpm = async (file: File) => {
|
|
359
|
+
file.arrayBuffer().then((arrayBuffer: ArrayBuffer) => {
|
|
360
|
+
processFile(arrayBuffer);
|
|
361
|
+
});
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const resetMoodResults = () => {
|
|
365
|
+
moodResults.value.forEach((moodResult) => {
|
|
366
|
+
moodResult.value = 0;
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
getKeyMoodAndBpm,
|
|
372
|
+
keyBpmResults,
|
|
373
|
+
moodResults,
|
|
374
|
+
resetMoodResults,
|
|
375
|
+
essentia,
|
|
376
|
+
essentiaAnalysis,
|
|
377
|
+
featureExtractionWorker
|
|
378
|
+
};
|
|
379
|
+
};
|