@aj-archipelago/cortex 1.3.5 → 1.3.7
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/helper-apps/cortex-autogen/agents.py +31 -2
- package/helper-apps/cortex-realtime-voice-server/.env.sample +6 -0
- package/helper-apps/cortex-realtime-voice-server/README.md +22 -0
- package/helper-apps/cortex-realtime-voice-server/bun.lockb +0 -0
- package/helper-apps/cortex-realtime-voice-server/client/bun.lockb +0 -0
- package/helper-apps/cortex-realtime-voice-server/client/index.html +12 -0
- package/helper-apps/cortex-realtime-voice-server/client/package.json +65 -0
- package/helper-apps/cortex-realtime-voice-server/client/postcss.config.js +6 -0
- package/helper-apps/cortex-realtime-voice-server/client/public/favicon.ico +0 -0
- package/helper-apps/cortex-realtime-voice-server/client/public/index.html +43 -0
- package/helper-apps/cortex-realtime-voice-server/client/public/logo192.png +0 -0
- package/helper-apps/cortex-realtime-voice-server/client/public/logo512.png +0 -0
- package/helper-apps/cortex-realtime-voice-server/client/public/manifest.json +25 -0
- package/helper-apps/cortex-realtime-voice-server/client/public/robots.txt +3 -0
- package/helper-apps/cortex-realtime-voice-server/client/public/sounds/connect.mp3 +0 -0
- package/helper-apps/cortex-realtime-voice-server/client/public/sounds/disconnect.mp3 +0 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/App.test.tsx +9 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/App.tsx +126 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/SettingsModal.tsx +207 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/Chat.tsx +553 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/ChatBubble.tsx +22 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/ChatBubbleLeft.tsx +22 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/ChatBubbleRight.tsx +21 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/ChatMessage.tsx +27 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/ChatMessageInput.tsx +74 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/ChatTile.tsx +211 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/audio/SoundEffects.ts +56 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/audio/WavPacker.ts +112 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/audio/WavRecorder.ts +571 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/audio/WavStreamPlayer.ts +290 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/audio/analysis/AudioAnalysis.ts +186 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/audio/analysis/constants.ts +59 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/audio/worklets/AudioProcessor.ts +214 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/audio/worklets/StreamProcessor.ts +183 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/components/AudioVisualizer.tsx +151 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/components/CopyButton.tsx +32 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/components/ImageOverlay.tsx +166 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/components/MicrophoneVisualizer.tsx +95 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/components/ScreenshotCapture.tsx +116 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/hooks/useWindowResize.ts +27 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/chat/utils/audio.ts +33 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/index.css +20 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/index.tsx +19 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/logo.svg +1 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/react-app-env.d.ts +1 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/reportWebVitals.ts +15 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/setupTests.ts +5 -0
- package/helper-apps/cortex-realtime-voice-server/client/src/utils/logger.ts +45 -0
- package/helper-apps/cortex-realtime-voice-server/client/tailwind.config.js +14 -0
- package/helper-apps/cortex-realtime-voice-server/client/tsconfig.json +30 -0
- package/helper-apps/cortex-realtime-voice-server/client/vite.config.ts +22 -0
- package/helper-apps/cortex-realtime-voice-server/index.ts +19 -0
- package/helper-apps/cortex-realtime-voice-server/package.json +28 -0
- package/helper-apps/cortex-realtime-voice-server/src/ApiServer.ts +35 -0
- package/helper-apps/cortex-realtime-voice-server/src/SocketServer.ts +737 -0
- package/helper-apps/cortex-realtime-voice-server/src/Tools.ts +520 -0
- package/helper-apps/cortex-realtime-voice-server/src/cortex/expert.ts +29 -0
- package/helper-apps/cortex-realtime-voice-server/src/cortex/image.ts +29 -0
- package/helper-apps/cortex-realtime-voice-server/src/cortex/memory.ts +91 -0
- package/helper-apps/cortex-realtime-voice-server/src/cortex/reason.ts +29 -0
- package/helper-apps/cortex-realtime-voice-server/src/cortex/search.ts +30 -0
- package/helper-apps/cortex-realtime-voice-server/src/cortex/style.ts +31 -0
- package/helper-apps/cortex-realtime-voice-server/src/cortex/utils.ts +95 -0
- package/helper-apps/cortex-realtime-voice-server/src/cortex/vision.ts +34 -0
- package/helper-apps/cortex-realtime-voice-server/src/realtime/client.ts +499 -0
- package/helper-apps/cortex-realtime-voice-server/src/realtime/realtimeTypes.ts +279 -0
- package/helper-apps/cortex-realtime-voice-server/src/realtime/socket.ts +27 -0
- package/helper-apps/cortex-realtime-voice-server/src/realtime/transcription.ts +75 -0
- package/helper-apps/cortex-realtime-voice-server/src/realtime/utils.ts +33 -0
- package/helper-apps/cortex-realtime-voice-server/src/utils/logger.ts +45 -0
- package/helper-apps/cortex-realtime-voice-server/src/utils/prompt.ts +81 -0
- package/helper-apps/cortex-realtime-voice-server/tsconfig.json +28 -0
- package/package.json +1 -1
- package/pathways/basePathway.js +3 -1
- package/pathways/system/entity/memory/sys_memory_manager.js +3 -0
- package/pathways/system/entity/memory/sys_memory_update.js +44 -45
- package/pathways/system/entity/memory/sys_read_memory.js +86 -6
- package/pathways/system/entity/memory/sys_search_memory.js +66 -0
- package/pathways/system/entity/shared/sys_entity_constants.js +2 -2
- package/pathways/system/entity/sys_entity_continue.js +2 -1
- package/pathways/system/entity/sys_entity_start.js +10 -0
- package/pathways/system/entity/sys_generator_expert.js +0 -2
- package/pathways/system/entity/sys_generator_memory.js +31 -0
- package/pathways/system/entity/sys_generator_voice_sample.js +36 -0
- package/pathways/system/entity/sys_router_tool.js +13 -10
- package/pathways/system/sys_parse_numbered_object_list.js +1 -1
- package/server/pathwayResolver.js +41 -31
- package/server/plugins/azureVideoTranslatePlugin.js +28 -16
- package/server/plugins/claude3VertexPlugin.js +0 -9
- package/server/plugins/gemini15ChatPlugin.js +18 -5
- package/server/plugins/modelPlugin.js +27 -6
- package/server/plugins/openAiChatPlugin.js +10 -8
- package/server/plugins/openAiVisionPlugin.js +56 -0
- package/tests/memoryfunction.test.js +73 -1
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { StreamProcessorSrc } from './worklets/StreamProcessor';
|
|
2
|
+
import { AudioAnalysis, AudioAnalysisOutputType } from './analysis/AudioAnalysis';
|
|
3
|
+
|
|
4
|
+
interface WavStreamPlayerOptions {
|
|
5
|
+
sampleRate?: number;
|
|
6
|
+
minBufferSize?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface TrackSampleOffset {
|
|
10
|
+
trackId: string | null;
|
|
11
|
+
offset: number;
|
|
12
|
+
currentTime: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Plays audio streams received in raw PCM16 chunks from the browser
|
|
17
|
+
*/
|
|
18
|
+
export class WavStreamPlayer {
|
|
19
|
+
private readonly scriptSrc: string;
|
|
20
|
+
private readonly sampleRate: number;
|
|
21
|
+
private readonly minBufferSize: number;
|
|
22
|
+
private context: AudioContext | null;
|
|
23
|
+
private stream: AudioWorkletNode | null;
|
|
24
|
+
private analyser: AnalyserNode | null;
|
|
25
|
+
private trackSampleOffsets: Record<string, TrackSampleOffset>;
|
|
26
|
+
private interruptedTrackIds: Record<string, boolean>;
|
|
27
|
+
private isRestarting: boolean;
|
|
28
|
+
public onTrackComplete?: (trackId: string) => void;
|
|
29
|
+
public currentTrackId: string | null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Creates a new WavStreamPlayer instance
|
|
33
|
+
* @param options
|
|
34
|
+
*/
|
|
35
|
+
constructor({ sampleRate = 44100, minBufferSize = 10 }: WavStreamPlayerOptions = {}) {
|
|
36
|
+
this.scriptSrc = StreamProcessorSrc;
|
|
37
|
+
this.sampleRate = sampleRate;
|
|
38
|
+
this.minBufferSize = minBufferSize;
|
|
39
|
+
this.context = null;
|
|
40
|
+
this.stream = null;
|
|
41
|
+
this.analyser = null;
|
|
42
|
+
this.trackSampleOffsets = {};
|
|
43
|
+
this.interruptedTrackIds = {};
|
|
44
|
+
this.isRestarting = false;
|
|
45
|
+
this.currentTrackId = null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Connects the audio context and enables output to speakers
|
|
50
|
+
*/
|
|
51
|
+
async connect(): Promise<boolean> {
|
|
52
|
+
this.context = new AudioContext({ sampleRate: this.sampleRate });
|
|
53
|
+
if (this.context.state === 'suspended') {
|
|
54
|
+
await this.context.resume();
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
await this.context.audioWorklet.addModule(this.scriptSrc);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
console.error(e);
|
|
60
|
+
throw new Error(`Could not add audioWorklet module: ${this.scriptSrc}`);
|
|
61
|
+
}
|
|
62
|
+
const analyser = this.context.createAnalyser();
|
|
63
|
+
analyser.fftSize = 1024;
|
|
64
|
+
analyser.smoothingTimeConstant = 0.8;
|
|
65
|
+
this.analyser = analyser;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Gets the current frequency domain data from the playing track
|
|
71
|
+
* @param analysisType
|
|
72
|
+
* @param minDecibels default -100
|
|
73
|
+
* @param maxDecibels default -30
|
|
74
|
+
*/
|
|
75
|
+
getFrequencies(
|
|
76
|
+
analysisType: 'frequency' | 'music' | 'voice' = 'frequency',
|
|
77
|
+
minDecibels = -100,
|
|
78
|
+
maxDecibels = -30,
|
|
79
|
+
): AudioAnalysisOutputType {
|
|
80
|
+
if (!this.analyser) {
|
|
81
|
+
throw new Error('Not connected, please call .connect() first');
|
|
82
|
+
}
|
|
83
|
+
return AudioAnalysis.getFrequencies(
|
|
84
|
+
this.analyser,
|
|
85
|
+
this.sampleRate,
|
|
86
|
+
null,
|
|
87
|
+
analysisType,
|
|
88
|
+
minDecibels,
|
|
89
|
+
maxDecibels,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Starts audio streaming
|
|
95
|
+
* @private
|
|
96
|
+
*/
|
|
97
|
+
private _start(): boolean {
|
|
98
|
+
if (!this.context) {
|
|
99
|
+
throw new Error('AudioContext not initialized');
|
|
100
|
+
}
|
|
101
|
+
if (this.isRestarting) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const streamNode = new AudioWorkletNode(this.context, 'stream_processor');
|
|
106
|
+
streamNode.connect(this.context.destination);
|
|
107
|
+
streamNode.port.onmessage = (e: MessageEvent) => {
|
|
108
|
+
const { event } = e.data;
|
|
109
|
+
if (event === 'stop') {
|
|
110
|
+
streamNode.disconnect();
|
|
111
|
+
this.stream = null;
|
|
112
|
+
this.isRestarting = false;
|
|
113
|
+
if (e.data.reason === 'max_underruns_reached') {
|
|
114
|
+
console.warn(`Audio stream stopped due to ${e.data.finalCount} consecutive underruns`);
|
|
115
|
+
}
|
|
116
|
+
} else if (event === 'offset') {
|
|
117
|
+
const { requestId, trackId, offset } = e.data;
|
|
118
|
+
const currentTime = offset / this.sampleRate;
|
|
119
|
+
this.trackSampleOffsets[requestId] = { trackId, offset, currentTime };
|
|
120
|
+
} else if (event === 'track_complete') {
|
|
121
|
+
const { trackId } = e.data;
|
|
122
|
+
this.onTrackComplete?.(trackId);
|
|
123
|
+
} else if (event === 'error') {
|
|
124
|
+
console.error('Stream processor error:', e.data.error);
|
|
125
|
+
this._handleStreamError();
|
|
126
|
+
} else if (event === 'underrun') {
|
|
127
|
+
console.warn(
|
|
128
|
+
`Audio buffer underrun: ${e.data.count} frames without data. ` +
|
|
129
|
+
`Buffer size: ${e.data.bufferSize}/${e.data.maxBuffers}`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
if (this.analyser) {
|
|
134
|
+
this.analyser.disconnect();
|
|
135
|
+
streamNode.connect(this.analyser);
|
|
136
|
+
}
|
|
137
|
+
this.stream = streamNode;
|
|
138
|
+
// Send minBufferSize to the worklet
|
|
139
|
+
streamNode.port.postMessage({ event: 'config', minBufferSize: this.minBufferSize });
|
|
140
|
+
return true;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error('Error starting stream:', error);
|
|
143
|
+
this.isRestarting = false;
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Handles stream errors by attempting to restart
|
|
150
|
+
* @private
|
|
151
|
+
*/
|
|
152
|
+
private async _handleStreamError() {
|
|
153
|
+
if (this.isRestarting) return;
|
|
154
|
+
|
|
155
|
+
this.isRestarting = true;
|
|
156
|
+
try {
|
|
157
|
+
if (this.stream) {
|
|
158
|
+
this.stream.disconnect();
|
|
159
|
+
this.stream = null;
|
|
160
|
+
}
|
|
161
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
162
|
+
this._start();
|
|
163
|
+
} finally {
|
|
164
|
+
this.isRestarting = false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Adds 16BitPCM data to the currently playing audio stream
|
|
170
|
+
* You can add chunks beyond the current play point and they will be queued for play
|
|
171
|
+
* @param arrayBuffer
|
|
172
|
+
* @param trackId
|
|
173
|
+
*/
|
|
174
|
+
public add16BitPCM(pcmData: ArrayBuffer, trackId: string) {
|
|
175
|
+
if (!this.context || !this.analyser) {
|
|
176
|
+
return new Int16Array();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
this.currentTrackId = trackId;
|
|
180
|
+
try {
|
|
181
|
+
if (this.interruptedTrackIds[trackId]) {
|
|
182
|
+
return new Int16Array();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!this.stream && !this._start()) {
|
|
186
|
+
throw new Error('Failed to start audio stream');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let buffer: Int16Array;
|
|
190
|
+
try {
|
|
191
|
+
if (pcmData instanceof Int16Array) {
|
|
192
|
+
buffer = pcmData;
|
|
193
|
+
} else {
|
|
194
|
+
buffer = new Int16Array(pcmData);
|
|
195
|
+
}
|
|
196
|
+
} catch (error) {
|
|
197
|
+
console.error('Error creating Int16Array:', error);
|
|
198
|
+
return new Int16Array();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!buffer.length) {
|
|
202
|
+
console.warn('Received empty buffer for track:', trackId);
|
|
203
|
+
return buffer;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this.stream?.port.postMessage({ event: 'write', buffer, trackId });
|
|
207
|
+
return buffer;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
console.error('Error processing audio chunk:', error);
|
|
210
|
+
this._handleStreamError();
|
|
211
|
+
return new Int16Array();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Clears the interrupted state for a track
|
|
217
|
+
* @param trackId
|
|
218
|
+
*/
|
|
219
|
+
clearInterruptedState(trackId: string): void {
|
|
220
|
+
delete this.interruptedTrackIds[trackId];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Clears all interrupted states
|
|
225
|
+
*/
|
|
226
|
+
clearAllInterruptedStates(): void {
|
|
227
|
+
this.interruptedTrackIds = {};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Gets the offset (sample count) of the currently playing stream
|
|
232
|
+
* @param interrupt
|
|
233
|
+
*/
|
|
234
|
+
async getTrackSampleOffset(interrupt = false): Promise<TrackSampleOffset | null> {
|
|
235
|
+
if (!this.stream) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const requestId = crypto.randomUUID();
|
|
239
|
+
this.stream.port.postMessage({
|
|
240
|
+
event: interrupt ? 'interrupt' : 'offset',
|
|
241
|
+
requestId,
|
|
242
|
+
});
|
|
243
|
+
let trackSampleOffset: TrackSampleOffset | undefined;
|
|
244
|
+
while (!trackSampleOffset) {
|
|
245
|
+
trackSampleOffset = this.trackSampleOffsets[requestId];
|
|
246
|
+
await new Promise((r) => setTimeout(() => r(null), 1));
|
|
247
|
+
}
|
|
248
|
+
const { trackId } = trackSampleOffset;
|
|
249
|
+
if (interrupt && trackId) {
|
|
250
|
+
this.interruptedTrackIds[trackId] = true;
|
|
251
|
+
}
|
|
252
|
+
return trackSampleOffset;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Strips the current stream and returns the sample offset of the audio
|
|
257
|
+
*/
|
|
258
|
+
async interrupt(): Promise<TrackSampleOffset | null> {
|
|
259
|
+
return this.getTrackSampleOffset(true);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Gets the analyser node
|
|
264
|
+
*/
|
|
265
|
+
getAnalyser(): AnalyserNode | null {
|
|
266
|
+
return this.analyser;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Sets a callback to be called when a track completes playback
|
|
271
|
+
* @param callback The callback function that receives the trackId
|
|
272
|
+
*/
|
|
273
|
+
setTrackCompleteCallback(callback: (trackId: string) => void) {
|
|
274
|
+
this.onTrackComplete = callback;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async fadeOut(durationMs: number) {
|
|
278
|
+
if (!this.context) return;
|
|
279
|
+
const gainNode = this.context.createGain();
|
|
280
|
+
gainNode.gain.setValueAtTime(1, this.context.currentTime);
|
|
281
|
+
gainNode.gain.linearRampToValueAtTime(0, this.context.currentTime + durationMs / 1000);
|
|
282
|
+
|
|
283
|
+
// Insert gain node before destination
|
|
284
|
+
this.stream?.disconnect();
|
|
285
|
+
this.stream?.connect(gainNode);
|
|
286
|
+
gainNode.connect(this.context.destination);
|
|
287
|
+
|
|
288
|
+
return new Promise(resolve => setTimeout(resolve, durationMs));
|
|
289
|
+
}
|
|
290
|
+
}
|
package/helper-apps/cortex-realtime-voice-server/client/src/chat/audio/analysis/AudioAnalysis.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import {
|
|
2
|
+
noteFrequencies,
|
|
3
|
+
noteFrequencyLabels,
|
|
4
|
+
voiceFrequencies,
|
|
5
|
+
voiceFrequencyLabels,
|
|
6
|
+
} from './constants';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Output of AudioAnalysis for the frequency domain of the audio
|
|
10
|
+
*/
|
|
11
|
+
export interface AudioAnalysisOutputType {
|
|
12
|
+
values: Float32Array;
|
|
13
|
+
frequencies: number[];
|
|
14
|
+
labels: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type AnalysisType = 'frequency' | 'music' | 'voice';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Analyzes audio for visual output
|
|
21
|
+
*/
|
|
22
|
+
export class AudioAnalysis {
|
|
23
|
+
private audio: HTMLAudioElement;
|
|
24
|
+
private context: AudioContext | OfflineAudioContext;
|
|
25
|
+
private analyser: AnalyserNode;
|
|
26
|
+
private sampleRate: number;
|
|
27
|
+
private audioBuffer: AudioBuffer | null;
|
|
28
|
+
private fftResults: Float32Array[] = [];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Retrieves frequency domain data from an AnalyserNode adjusted to a decibel range
|
|
32
|
+
* returns human-readable formatting and labels
|
|
33
|
+
*/
|
|
34
|
+
static getFrequencies(
|
|
35
|
+
analyser: AnalyserNode,
|
|
36
|
+
sampleRate: number,
|
|
37
|
+
fftResult: Float32Array | null,
|
|
38
|
+
analysisType: AnalysisType = 'frequency',
|
|
39
|
+
minDecibels: number = -100,
|
|
40
|
+
maxDecibels: number = -30
|
|
41
|
+
): AudioAnalysisOutputType {
|
|
42
|
+
if (!fftResult) {
|
|
43
|
+
fftResult = new Float32Array(analyser.frequencyBinCount);
|
|
44
|
+
analyser.getFloatFrequencyData(fftResult);
|
|
45
|
+
}
|
|
46
|
+
const nyquistFrequency = sampleRate / 2;
|
|
47
|
+
const frequencyStep = (1 / fftResult.length) * nyquistFrequency;
|
|
48
|
+
let outputValues: number[];
|
|
49
|
+
let frequencies: number[];
|
|
50
|
+
let labels: string[];
|
|
51
|
+
|
|
52
|
+
if (analysisType === 'music' || analysisType === 'voice') {
|
|
53
|
+
const useFrequencies = analysisType === 'voice' ? voiceFrequencies : noteFrequencies;
|
|
54
|
+
const aggregateOutput = Array(useFrequencies.length).fill(minDecibels);
|
|
55
|
+
for (let i = 0; i < fftResult.length; i++) {
|
|
56
|
+
const frequency = i * frequencyStep;
|
|
57
|
+
const amplitude = fftResult[i] || 0;
|
|
58
|
+
for (let n = useFrequencies.length - 1; n >= 0; n--) {
|
|
59
|
+
const useFrequency = useFrequencies[n] || 0;
|
|
60
|
+
if (frequency > useFrequency) {
|
|
61
|
+
aggregateOutput[n] = Math.max(aggregateOutput[n], amplitude);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
outputValues = aggregateOutput;
|
|
67
|
+
frequencies = analysisType === 'voice' ? voiceFrequencies : noteFrequencies;
|
|
68
|
+
labels = analysisType === 'voice' ? voiceFrequencyLabels : noteFrequencyLabels;
|
|
69
|
+
} else {
|
|
70
|
+
outputValues = Array.from(fftResult);
|
|
71
|
+
frequencies = outputValues.map((_, i) => frequencyStep * i);
|
|
72
|
+
labels = frequencies.map((f) => `${f.toFixed(2)} Hz`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// We normalize to {0, 1}
|
|
76
|
+
const normalizedOutput = outputValues.map((v) =>
|
|
77
|
+
Math.max(0, Math.min((v - minDecibels) / (maxDecibels - minDecibels), 1))
|
|
78
|
+
);
|
|
79
|
+
const values = new Float32Array(normalizedOutput);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
values,
|
|
83
|
+
frequencies,
|
|
84
|
+
labels,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Creates a new AudioAnalysis instance for an HTMLAudioElement
|
|
90
|
+
*/
|
|
91
|
+
constructor(audioElement: HTMLAudioElement, audioBuffer: AudioBuffer | null = null) {
|
|
92
|
+
this.audio = audioElement;
|
|
93
|
+
this.audioBuffer = audioBuffer;
|
|
94
|
+
|
|
95
|
+
if (audioBuffer) {
|
|
96
|
+
const { length, sampleRate } = audioBuffer;
|
|
97
|
+
const offlineAudioContext = new OfflineAudioContext({
|
|
98
|
+
length,
|
|
99
|
+
sampleRate,
|
|
100
|
+
});
|
|
101
|
+
const source = offlineAudioContext.createBufferSource();
|
|
102
|
+
source.buffer = audioBuffer;
|
|
103
|
+
const analyser = offlineAudioContext.createAnalyser();
|
|
104
|
+
analyser.fftSize = 8192;
|
|
105
|
+
analyser.smoothingTimeConstant = 0.1;
|
|
106
|
+
source.connect(analyser);
|
|
107
|
+
|
|
108
|
+
const renderQuantumInSeconds = 1 / 60;
|
|
109
|
+
const durationInSeconds = length / sampleRate;
|
|
110
|
+
|
|
111
|
+
const analyze = (index: number) => {
|
|
112
|
+
const suspendTime = renderQuantumInSeconds * index;
|
|
113
|
+
if (suspendTime < durationInSeconds) {
|
|
114
|
+
offlineAudioContext.suspend(suspendTime).then(() => {
|
|
115
|
+
const fftResult = new Float32Array(analyser.frequencyBinCount);
|
|
116
|
+
analyser.getFloatFrequencyData(fftResult);
|
|
117
|
+
this.fftResults.push(fftResult);
|
|
118
|
+
analyze(index + 1);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (index === 1) {
|
|
122
|
+
offlineAudioContext.startRendering();
|
|
123
|
+
} else {
|
|
124
|
+
offlineAudioContext.resume();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
source.start(0);
|
|
129
|
+
analyze(1);
|
|
130
|
+
|
|
131
|
+
this.context = offlineAudioContext;
|
|
132
|
+
this.analyser = analyser;
|
|
133
|
+
this.sampleRate = sampleRate;
|
|
134
|
+
} else {
|
|
135
|
+
const audioContext = new AudioContext();
|
|
136
|
+
const track = audioContext.createMediaElementSource(audioElement);
|
|
137
|
+
const analyser = audioContext.createAnalyser();
|
|
138
|
+
analyser.fftSize = 8192;
|
|
139
|
+
analyser.smoothingTimeConstant = 0.1;
|
|
140
|
+
track.connect(analyser);
|
|
141
|
+
analyser.connect(audioContext.destination);
|
|
142
|
+
|
|
143
|
+
this.context = audioContext;
|
|
144
|
+
this.analyser = analyser;
|
|
145
|
+
this.sampleRate = this.context.sampleRate;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Gets the current frequency domain data from the playing audio track
|
|
151
|
+
*/
|
|
152
|
+
getFrequencies(
|
|
153
|
+
analysisType: AnalysisType = 'frequency',
|
|
154
|
+
minDecibels: number = -100,
|
|
155
|
+
maxDecibels: number = -30
|
|
156
|
+
): AudioAnalysisOutputType {
|
|
157
|
+
let fftResult: Float32Array | null = null;
|
|
158
|
+
if (this.audioBuffer && this.fftResults.length) {
|
|
159
|
+
const pct = this.audio.currentTime / this.audio.duration;
|
|
160
|
+
const index = Math.min(
|
|
161
|
+
Math.floor(pct * this.fftResults.length),
|
|
162
|
+
this.fftResults.length - 1
|
|
163
|
+
);
|
|
164
|
+
fftResult = this.fftResults[index] ?? null;
|
|
165
|
+
}
|
|
166
|
+
return AudioAnalysis.getFrequencies(
|
|
167
|
+
this.analyser,
|
|
168
|
+
this.sampleRate,
|
|
169
|
+
fftResult ?? null,
|
|
170
|
+
analysisType,
|
|
171
|
+
minDecibels,
|
|
172
|
+
maxDecibels
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Resume the internal AudioContext if it was suspended due to the lack of
|
|
178
|
+
* user interaction when the AudioAnalysis was instantiated.
|
|
179
|
+
*/
|
|
180
|
+
async resumeIfSuspended(): Promise<true> {
|
|
181
|
+
if (this.context.state === 'suspended') {
|
|
182
|
+
await this.context.resume();
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constants for help with visualization
|
|
3
|
+
* Helps map frequency ranges from Fast Fourier Transform
|
|
4
|
+
* to human-interpretable ranges, notably music ranges and
|
|
5
|
+
* human vocal ranges.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Eighth octave frequencies
|
|
9
|
+
const octave8Frequencies: number[] = [
|
|
10
|
+
4186.01, 4434.92, 4698.63, 4978.03, 5274.04, 5587.65, 5919.91, 6271.93,
|
|
11
|
+
6644.88, 7040.0, 7458.62, 7902.13,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
// Labels for each of the above frequencies
|
|
15
|
+
const octave8FrequencyLabels: string[] = [
|
|
16
|
+
'C',
|
|
17
|
+
'C#',
|
|
18
|
+
'D',
|
|
19
|
+
'D#',
|
|
20
|
+
'E',
|
|
21
|
+
'F',
|
|
22
|
+
'F#',
|
|
23
|
+
'G',
|
|
24
|
+
'G#',
|
|
25
|
+
'A',
|
|
26
|
+
'A#',
|
|
27
|
+
'B',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* All note frequencies from 1st to 8th octave
|
|
32
|
+
* in format "A#8" (A#, 8th octave)
|
|
33
|
+
*/
|
|
34
|
+
export const noteFrequencies: number[] = [];
|
|
35
|
+
export const noteFrequencyLabels: string[] = [];
|
|
36
|
+
for (let i = 1; i <= 8; i++) {
|
|
37
|
+
for (let f = 0; f < octave8Frequencies.length; f++) {
|
|
38
|
+
const freq = octave8Frequencies[f] || 0;
|
|
39
|
+
const baseNote = octave8FrequencyLabels[f] || 'C';
|
|
40
|
+
noteFrequencies.push(freq / Math.pow(2, 8 - i));
|
|
41
|
+
noteFrequencyLabels.push( baseNote + i);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Subset of the note frequencies between 32 and 2000 Hz
|
|
47
|
+
* 6 octave range: C1 to B6
|
|
48
|
+
*/
|
|
49
|
+
const voiceFrequencyRange: [number, number] = [32.0, 2000.0];
|
|
50
|
+
export const voiceFrequencies: number[] = noteFrequencies.filter((freq) => {
|
|
51
|
+
return freq > voiceFrequencyRange[0] && freq < voiceFrequencyRange[1];
|
|
52
|
+
});
|
|
53
|
+
export const voiceFrequencyLabels: string[] = noteFrequencyLabels.filter((_, i) => {
|
|
54
|
+
return (
|
|
55
|
+
noteFrequencies[i] &&
|
|
56
|
+
noteFrequencies[i] > voiceFrequencyRange[0] &&
|
|
57
|
+
noteFrequencies[i] < voiceFrequencyRange[1]
|
|
58
|
+
);
|
|
59
|
+
})
|