@prabhjeet.me/wakeywakey 1.1.0 → 2.1.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/fesm2022/prabhjeet.me-wakeywakey.mjs +576 -195
- package/fesm2022/prabhjeet.me-wakeywakey.mjs.map +1 -1
- package/index.d.ts +147 -19
- package/package.json +1 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { InjectionToken, inject,
|
|
3
|
-
import { Subject, withLatestFrom, concatMap,
|
|
2
|
+
import { Injectable, InjectionToken, inject, PLATFORM_ID, NgZone, HostListener, ViewChild, Component, EventEmitter, Output, provideAppInitializer } from '@angular/core';
|
|
3
|
+
import { Subject, filter, withLatestFrom, concatMap, map, tap, distinctUntilChanged, switchMap, EMPTY, ignoreElements, timer, merge, take, throttleTime, share, takeUntil, scan } from 'rxjs';
|
|
4
4
|
import { SubSink } from 'subsink';
|
|
5
5
|
import { Tensor, env, InferenceSession } from 'onnxruntime-web';
|
|
6
6
|
import { loadRnnoise, RnnoiseWorkletNode } from '@sapphi-red/web-noise-suppressor';
|
|
7
|
-
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
|
7
|
+
import { isPlatformBrowser, isPlatformServer, NgClass } from '@angular/common';
|
|
8
8
|
import * as THREE from 'three';
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -143,6 +143,25 @@ class AudioUtil {
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
class OrbComponentService {
|
|
147
|
+
constructor() {
|
|
148
|
+
this.state = new Subject();
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Set state of orb
|
|
152
|
+
*/
|
|
153
|
+
setState(state) {
|
|
154
|
+
this.state.next(state);
|
|
155
|
+
}
|
|
156
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.16", ngImport: i0, type: OrbComponentService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
157
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.16", ngImport: i0, type: OrbComponentService }); }
|
|
158
|
+
}
|
|
159
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.16", ngImport: i0, type: OrbComponentService, decorators: [{
|
|
160
|
+
type: Injectable
|
|
161
|
+
}] });
|
|
162
|
+
|
|
163
|
+
const DEFAULT_THROTTLE_TIME = 1000;
|
|
164
|
+
|
|
146
165
|
/**
|
|
147
166
|
* Wakey wakey config token
|
|
148
167
|
*/
|
|
@@ -174,7 +193,7 @@ class ConfigService {
|
|
|
174
193
|
* Throttle time
|
|
175
194
|
*/
|
|
176
195
|
get throttleTime() {
|
|
177
|
-
return this._config.throttleTime;
|
|
196
|
+
return this._config.throttleTime ?? DEFAULT_THROTTLE_TIME;
|
|
178
197
|
}
|
|
179
198
|
/**
|
|
180
199
|
* Mode
|
|
@@ -186,7 +205,13 @@ class ConfigService {
|
|
|
186
205
|
* Base path of assets
|
|
187
206
|
*/
|
|
188
207
|
get basePath() {
|
|
189
|
-
return this._config.basePath
|
|
208
|
+
return this._config.basePath ?? '/wakeywakey';
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Hotkey
|
|
212
|
+
*/
|
|
213
|
+
get hotkey() {
|
|
214
|
+
return this._config.hotkey ?? 'Space';
|
|
190
215
|
}
|
|
191
216
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.16", ngImport: i0, type: ConfigService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
192
217
|
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.16", ngImport: i0, type: ConfigService }); }
|
|
@@ -400,6 +425,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.16", ngImpo
|
|
|
400
425
|
}] });
|
|
401
426
|
|
|
402
427
|
const DEFAULT_SILENCE_DURATION = 1000;
|
|
428
|
+
const DEFAULT_SPEECH_THRESHOLD_TIME = 300;
|
|
403
429
|
|
|
404
430
|
class MicrophoneService {
|
|
405
431
|
constructor() {
|
|
@@ -416,6 +442,10 @@ class MicrophoneService {
|
|
|
416
442
|
* List of available microphones
|
|
417
443
|
*/
|
|
418
444
|
this._microphones = [];
|
|
445
|
+
/**
|
|
446
|
+
* Is muted
|
|
447
|
+
*/
|
|
448
|
+
this._isMuted = false;
|
|
419
449
|
}
|
|
420
450
|
/**
|
|
421
451
|
* List of available microphones
|
|
@@ -429,6 +459,43 @@ class MicrophoneService {
|
|
|
429
459
|
get data() {
|
|
430
460
|
return this._data;
|
|
431
461
|
}
|
|
462
|
+
/**
|
|
463
|
+
* Audio context
|
|
464
|
+
*/
|
|
465
|
+
get audioContext() {
|
|
466
|
+
return this._audioContext;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Analyzer node
|
|
470
|
+
*/
|
|
471
|
+
get analyzer() {
|
|
472
|
+
return this._analyser;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Media steam source node
|
|
476
|
+
*/
|
|
477
|
+
get sourceNode() {
|
|
478
|
+
return this._source;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Muted state
|
|
482
|
+
*/
|
|
483
|
+
get isMuted() {
|
|
484
|
+
return this._isMuted;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Set gain
|
|
488
|
+
*/
|
|
489
|
+
set gain(value) {
|
|
490
|
+
if (this._gainNode)
|
|
491
|
+
this._gainNode.gain.value = value;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Set muted state
|
|
495
|
+
*/
|
|
496
|
+
set isMuted(set) {
|
|
497
|
+
this._isMuted = set;
|
|
498
|
+
}
|
|
432
499
|
/**
|
|
433
500
|
* Set input source
|
|
434
501
|
*/
|
|
@@ -438,6 +505,8 @@ class MicrophoneService {
|
|
|
438
505
|
ngOnDestroy() {
|
|
439
506
|
// close audio context
|
|
440
507
|
this._audioContext?.close();
|
|
508
|
+
this._source?.disconnect();
|
|
509
|
+
this._analyser?.disconnect();
|
|
441
510
|
this._stream?.getTracks().forEach((track) => {
|
|
442
511
|
track.stop();
|
|
443
512
|
});
|
|
@@ -453,13 +522,18 @@ class MicrophoneService {
|
|
|
453
522
|
this.ngOnDestroy();
|
|
454
523
|
// request permission
|
|
455
524
|
this._stream = await navigator.mediaDevices.getUserMedia({
|
|
456
|
-
audio: {
|
|
525
|
+
audio: {
|
|
526
|
+
deviceId: { exact: deviceId },
|
|
527
|
+
noiseSuppression: this._config.audio.noiseSuppression?.nativeNoiseSuppression || false,
|
|
528
|
+
echoCancellation: this._config.audio.noiseSuppression?.nativeEchoCancellation || false,
|
|
529
|
+
autoGainControl: this._config.audio.noiseSuppression?.autoGainControl || false,
|
|
530
|
+
},
|
|
457
531
|
});
|
|
458
532
|
this._event.log.next(`${MicrophoneService.name}: Microphone permission granted (deviceid: '${deviceId ?? 'default'}')!`);
|
|
459
533
|
// save list of microphones
|
|
460
534
|
this._microphones = await this._microphoneList();
|
|
461
535
|
// monitor audio
|
|
462
|
-
this._monitor();
|
|
536
|
+
await this._monitor();
|
|
463
537
|
return true;
|
|
464
538
|
}
|
|
465
539
|
catch (error) {
|
|
@@ -497,6 +571,8 @@ class MicrophoneService {
|
|
|
497
571
|
async _workletNode() {
|
|
498
572
|
// Create audio context
|
|
499
573
|
this._audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
|
|
574
|
+
this._analyser = this._audioContext.createAnalyser();
|
|
575
|
+
this._analyser.fftSize = 256;
|
|
500
576
|
if (this._config.audio.noiseSuppression) {
|
|
501
577
|
await this._audioContext.audioWorklet.addModule(this._config.audio.noiseSuppression.worklet ??
|
|
502
578
|
`${this._config.basePath}/worklets/workletProcessor.js`);
|
|
@@ -507,7 +583,7 @@ class MicrophoneService {
|
|
|
507
583
|
await this._audioContext.audioWorklet.addModule(workletURL);
|
|
508
584
|
URL.revokeObjectURL(workletURL);
|
|
509
585
|
// Create Nodes
|
|
510
|
-
|
|
586
|
+
this._source = this._audioContext.createMediaStreamSource(this._stream);
|
|
511
587
|
// Gain Node
|
|
512
588
|
const gainNode = this._audioContext.createGain();
|
|
513
589
|
gainNode.gain.value = this._config.audio.gain;
|
|
@@ -524,12 +600,16 @@ class MicrophoneService {
|
|
|
524
600
|
wasmBinary: rnnoiseWasmBinary,
|
|
525
601
|
maxChannels: 1, // Standard for mono microphone input
|
|
526
602
|
});
|
|
527
|
-
|
|
603
|
+
this._source.connect(rnnoiseNode);
|
|
528
604
|
rnnoiseNode.connect(gainNode);
|
|
529
605
|
}
|
|
530
606
|
else {
|
|
531
|
-
|
|
607
|
+
this._source.connect(gainNode);
|
|
532
608
|
}
|
|
609
|
+
this._source.connect(this._analyser);
|
|
610
|
+
// loop back mic sound
|
|
611
|
+
if (this._config.audio.loopBackToSpeakers)
|
|
612
|
+
this._analyser.connect(this.audioContext.destination);
|
|
533
613
|
// Custom Worklet Node
|
|
534
614
|
const workletNode = new AudioWorkletNode(this._audioContext, MICROPHONE_PROCESSOR_NAME);
|
|
535
615
|
// Connect the Graph: Source -> RNNoise (if noise suppression) -> Gain -> Custom Worklet
|
|
@@ -573,6 +653,7 @@ class SpeechRecognitionService {
|
|
|
573
653
|
*/
|
|
574
654
|
this._event = inject(EventService);
|
|
575
655
|
this._platform = inject(PlatformService);
|
|
656
|
+
this._mic = inject(MicrophoneService);
|
|
576
657
|
/**
|
|
577
658
|
* Transcript
|
|
578
659
|
*/
|
|
@@ -606,9 +687,16 @@ class SpeechRecognitionService {
|
|
|
606
687
|
this._recognition.lang = 'en-US'; // Set language
|
|
607
688
|
this._recognition.continuous = true; // Keep listening even if the user pauses
|
|
608
689
|
this._recognition.interimResults = true; // Show results while the user is still speaking
|
|
690
|
+
this._recognition.onend = () => {
|
|
691
|
+
this.reset();
|
|
692
|
+
this.init(); // start
|
|
693
|
+
};
|
|
609
694
|
// 3. Handle Results
|
|
610
695
|
this._recognition.onresult = (event) => {
|
|
611
696
|
this._transcript = '';
|
|
697
|
+
// Don't capture if muted
|
|
698
|
+
if (this._mic.isMuted)
|
|
699
|
+
return;
|
|
612
700
|
for (let i = event.resultIndex; i < event.results.length; i++)
|
|
613
701
|
this._transcript += event.results[i][0].transcript;
|
|
614
702
|
};
|
|
@@ -762,7 +850,8 @@ class AudioService {
|
|
|
762
850
|
// Fire a speech event
|
|
763
851
|
this._event.speech.next({
|
|
764
852
|
...data,
|
|
765
|
-
|
|
853
|
+
sample: this._mic.isMuted ? new Float32Array(1280).fill(0) : data.sample, // no speech data if muted
|
|
854
|
+
vadScore: await this._vad.score(data.sample), // emit vad score even if muted
|
|
766
855
|
get hasVoiceActivity() {
|
|
767
856
|
return this.vadScore > (that._config.audio.vadThreshold ?? DEFAULT_VAD_THRESHOLD);
|
|
768
857
|
},
|
|
@@ -794,6 +883,7 @@ class AudioService {
|
|
|
794
883
|
*/
|
|
795
884
|
forceEndRecording() {
|
|
796
885
|
this._endCurrentRecording = true;
|
|
886
|
+
this._isInitialized = false;
|
|
797
887
|
this._event.silence.next({
|
|
798
888
|
chunk: new Float32Array(),
|
|
799
889
|
transcript: '',
|
|
@@ -817,10 +907,24 @@ class AudioService {
|
|
|
817
907
|
_listenForWakeword() {
|
|
818
908
|
const vad$ = this._getWakeWordStream();
|
|
819
909
|
this._subs.sink = this._event.speech
|
|
820
|
-
.pipe(withLatestFrom(vad$), concatMap(async ([speech, vadState]) => {
|
|
910
|
+
.pipe(filter(() => !this._isInitialized && !this._mic.isMuted), withLatestFrom(vad$), concatMap(async ([speech, vadState]) => {
|
|
821
911
|
const score = await this._pipeline.run(speech);
|
|
822
912
|
return { speech, score, chunk: vadState.buffer };
|
|
823
|
-
}), filter(({ score }) =>
|
|
913
|
+
}), filter(({ score }) => {
|
|
914
|
+
if (this._config.onnx.wakeword) {
|
|
915
|
+
const transcriptArray = this._speechRecognition.transcript
|
|
916
|
+
.toLowerCase()
|
|
917
|
+
.trim()
|
|
918
|
+
.split(' ');
|
|
919
|
+
// use speech recognition for wake word identification
|
|
920
|
+
if (transcriptArray.length === this._config.onnx.wakeword.length)
|
|
921
|
+
for (const idx in this._config.onnx.wakeword)
|
|
922
|
+
if (+idx < transcriptArray.length &&
|
|
923
|
+
transcriptArray[idx].includes(this._config.onnx.wakeword[idx].toLowerCase()))
|
|
924
|
+
return true;
|
|
925
|
+
}
|
|
926
|
+
return score > (this._config.onnx.wakewordInferenceThreshold ?? DEFAULT_INFERENCE_SCORE);
|
|
927
|
+
}))
|
|
824
928
|
.subscribe(({ speech, score, chunk }) => {
|
|
825
929
|
this._event.wakeword.next({ ...speech, inferenceScore: score, chunk });
|
|
826
930
|
});
|
|
@@ -831,66 +935,88 @@ class AudioService {
|
|
|
831
935
|
_captureCommandAfterWakeword() {
|
|
832
936
|
const SILENCE_DURATION = this._config.audio.silenceDuration ?? DEFAULT_SILENCE_DURATION;
|
|
833
937
|
const VAD_THRESHOLD = this._config.audio.vadThreshold ?? DEFAULT_VAD_THRESHOLD;
|
|
834
|
-
|
|
938
|
+
const PRE_ROLL_MS = this._config.audio.speechThresholdTime ?? DEFAULT_SPEECH_THRESHOLD_TIME;
|
|
939
|
+
const CHUNK_DURATION_MS = 80; // Each speech event is fired (by audio processor) at 80 ms
|
|
940
|
+
const MAX_SLIDING_WINDOW_CHUNKS = Math.ceil(PRE_ROLL_MS / CHUNK_DURATION_MS);
|
|
941
|
+
// speech sliding window state
|
|
942
|
+
const speechSlidingWindow = [];
|
|
943
|
+
// trigger 1: wakeword
|
|
835
944
|
const wakewordTrigger$ = this._event.wakeword.pipe(filter(() => !this._isRecording), // Ignore wakeword if already recording
|
|
836
945
|
map(() => []));
|
|
837
|
-
//
|
|
838
|
-
const continuousVadTrigger$ = this._event.speech.pipe(
|
|
839
|
-
|
|
946
|
+
// trigger 2: Continuous VAD > THRESHOLD
|
|
947
|
+
const continuousVadTrigger$ = this._event.speech.pipe(filter(() => !this._mic.isMuted),
|
|
948
|
+
// intercept the raw stream to constantly update the threshold window
|
|
949
|
+
tap((s) => {
|
|
950
|
+
// Only buffer if we aren't already formally recording
|
|
951
|
+
if (!this._isRecording && this._isInitialized) {
|
|
952
|
+
speechSlidingWindow.push(s.sample);
|
|
953
|
+
// Keep the array length strictly to our configured window size
|
|
954
|
+
if (speechSlidingWindow.length > MAX_SLIDING_WINDOW_CHUNKS)
|
|
955
|
+
speechSlidingWindow.shift();
|
|
956
|
+
}
|
|
957
|
+
}),
|
|
958
|
+
// check VAD score
|
|
959
|
+
map((s) => s.vadScore > VAD_THRESHOLD), filter(() => !this._isRecording && this._isInitialized), distinctUntilChanged(), switchMap((isVoiceActive) => {
|
|
840
960
|
if (!isVoiceActive)
|
|
841
|
-
return EMPTY; // Cancel if voice stops
|
|
961
|
+
return EMPTY; // Cancel if voice stops before threshold
|
|
842
962
|
this._speechRecognition.reset();
|
|
843
|
-
|
|
844
|
-
//
|
|
845
|
-
const
|
|
846
|
-
//
|
|
847
|
-
const
|
|
848
|
-
//
|
|
849
|
-
|
|
963
|
+
// PRE-FILL the actual recording buffer with our duration (ex: 300ms) look back window!
|
|
964
|
+
// clone so future sliding window updates don't mutate the captured audio.
|
|
965
|
+
const bufferedChunks = [...speechSlidingWindow];
|
|
966
|
+
// continue accumulating new chunks silently while we wait for the timer
|
|
967
|
+
const buffer$ = this._event.speech.pipe(filter(() => !this._mic.isMuted), tap((s) => bufferedChunks.push(s.sample)), ignoreElements());
|
|
968
|
+
// timer that emits the fully accumulated chunks (pre-roll)
|
|
969
|
+
const timer$ = timer(PRE_ROLL_MS).pipe(map(() => bufferedChunks));
|
|
850
970
|
return merge(buffer$, timer$).pipe(take(1));
|
|
851
971
|
}));
|
|
852
|
-
//
|
|
972
|
+
// combine triggers
|
|
853
973
|
const startRecordingTrigger$ = merge(wakewordTrigger$.pipe(tap(() => {
|
|
854
974
|
if (!this._isInitialized)
|
|
855
975
|
this._isInitialized = true; // initialized
|
|
856
|
-
})), continuousVadTrigger$).pipe(throttleTime(
|
|
857
|
-
//
|
|
976
|
+
})), continuousVadTrigger$).pipe(filter(() => !this._mic.isMuted), throttleTime(this._config.throttleTime));
|
|
977
|
+
// recording pipeline
|
|
858
978
|
this._subs.sink = startRecordingTrigger$
|
|
859
979
|
.pipe(tap(() => {
|
|
860
980
|
this._isRecording = true;
|
|
861
981
|
this._speechRecognition.reset();
|
|
862
|
-
this._event.recording.next(); // recording event
|
|
863
982
|
}), switchMap((bufferedChunks) => {
|
|
864
983
|
// Initialize our command chunks with anything captured during the 1s VAD wait
|
|
865
984
|
const commandChunks = [...bufferedChunks];
|
|
866
|
-
const speech$ = this._event.speech.pipe(
|
|
985
|
+
const speech$ = this._event.speech.pipe(filter(() => !this._mic.isMuted), tap((speech) => {
|
|
986
|
+
commandChunks.push(speech.sample);
|
|
987
|
+
if (this._isRecording)
|
|
988
|
+
// emit recording events
|
|
989
|
+
this._event.recording.next({
|
|
990
|
+
chunk: this._flatten(commandChunks),
|
|
991
|
+
transcript: this._speechRecognition.transcript,
|
|
992
|
+
});
|
|
993
|
+
}), share());
|
|
867
994
|
const silence$ = speech$.pipe(map((s) => s.vadScore < VAD_THRESHOLD), distinctUntilChanged());
|
|
868
|
-
//
|
|
869
|
-
const normalSilenceTimeout$ = silence$.pipe(
|
|
995
|
+
// silence timeout logic
|
|
996
|
+
const normalSilenceTimeout$ = silence$.pipe(switchMap((isSilent) => {
|
|
870
997
|
if (!isSilent) {
|
|
871
998
|
return EMPTY; // if voice cancel the timer
|
|
872
999
|
}
|
|
873
1000
|
// silence started, start timer
|
|
874
1001
|
return timer(SILENCE_DURATION).pipe(takeUntil(silence$.pipe(filter((silent) => !silent))));
|
|
875
1002
|
}));
|
|
876
|
-
//
|
|
1003
|
+
// force end recording
|
|
877
1004
|
const forceComplete$ = speech$.pipe(filter(() => this._endCurrentRecording));
|
|
878
|
-
// 3. Complete whenever the timer fires OR the flag is set to true
|
|
879
1005
|
return merge(normalSilenceTimeout$, forceComplete$).pipe(take(1), map(() => this._flatten(commandChunks)));
|
|
880
1006
|
}))
|
|
881
1007
|
.subscribe({
|
|
882
1008
|
next: (chunk) => {
|
|
883
|
-
const interimResponse = this._config.mode === 'DEFAULT' ? false : true;
|
|
1009
|
+
const interimResponse = this._config.mode === 'DEFAULT' || this._config.mode === 'PTT' ? false : true;
|
|
884
1010
|
this._event.silence.next({
|
|
885
1011
|
chunk,
|
|
886
1012
|
transcript: this._speechRecognition.transcript,
|
|
887
1013
|
interimResponse,
|
|
888
1014
|
}); // emit silence event
|
|
889
1015
|
// Default case
|
|
890
|
-
if (this._config.mode === 'DEFAULT') {
|
|
1016
|
+
if (this._config.mode === 'DEFAULT' || this._config.mode === 'PTT') {
|
|
891
1017
|
this._isInitialized = false;
|
|
892
|
-
this._endCurrentRecording = false; // reset flag after recording ends
|
|
893
1018
|
}
|
|
1019
|
+
this._endCurrentRecording = false; // reset flag after recording ends
|
|
894
1020
|
this._isRecording = false;
|
|
895
1021
|
},
|
|
896
1022
|
error: (err) => {
|
|
@@ -916,7 +1042,7 @@ class AudioService {
|
|
|
916
1042
|
* @returns
|
|
917
1043
|
*/
|
|
918
1044
|
_getWakeWordStream() {
|
|
919
|
-
return this._event.speech.pipe(scan((state, speech) => {
|
|
1045
|
+
return this._event.speech.pipe(filter(() => !this._mic.isMuted), scan((state, speech) => {
|
|
920
1046
|
const { hasVoiceActivity, sample } = speech;
|
|
921
1047
|
let { isActive, hangoverCounter, buffer } = state;
|
|
922
1048
|
if (hasVoiceActivity) {
|
|
@@ -960,7 +1086,12 @@ class SpeakerService {
|
|
|
960
1086
|
this._config = inject(ConfigService);
|
|
961
1087
|
this._platform = inject(PlatformService);
|
|
962
1088
|
this._event = inject(EventService);
|
|
1089
|
+
this._mic = inject(MicrophoneService);
|
|
1090
|
+
this._orb = inject(OrbComponentService);
|
|
1091
|
+
this._audio = inject(AudioService);
|
|
963
1092
|
this._subs = new SubSink();
|
|
1093
|
+
this._nextPlayTime = 0;
|
|
1094
|
+
this._sources = [];
|
|
964
1095
|
if (this._config.audio.sound?.enable === false)
|
|
965
1096
|
return;
|
|
966
1097
|
// Audio is only available in browser context
|
|
@@ -974,6 +1105,55 @@ class SpeakerService {
|
|
|
974
1105
|
ngOnDestroy() {
|
|
975
1106
|
this._subs.unsubscribe();
|
|
976
1107
|
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Play audio chunk
|
|
1110
|
+
*
|
|
1111
|
+
* @param buffer audio buffer
|
|
1112
|
+
* @param sampleRate sample rate to play in
|
|
1113
|
+
*/
|
|
1114
|
+
playChunk(buffer, sampleRate) {
|
|
1115
|
+
// Calculate how many full 32-bit floats fit in this buffer
|
|
1116
|
+
const float32Count = Math.floor(buffer.byteLength / 4);
|
|
1117
|
+
// Convert raw bytes back to 32-bit floats
|
|
1118
|
+
const float32Array = new Float32Array(buffer, 0, float32Count);
|
|
1119
|
+
// Create an empty audio buffer mapping
|
|
1120
|
+
const audioBuffer = this._mic.audioContext.createBuffer(1, float32Array.length, sampleRate);
|
|
1121
|
+
audioBuffer.copyToChannel(float32Array, 0);
|
|
1122
|
+
this.playAudioBuffer(audioBuffer);
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Play audio buffer
|
|
1126
|
+
*
|
|
1127
|
+
* @param audioBuffer
|
|
1128
|
+
*/
|
|
1129
|
+
playAudioBuffer(audioBuffer) {
|
|
1130
|
+
// if recording, clear the queue
|
|
1131
|
+
if (this._audio.isRecording) {
|
|
1132
|
+
this._clearQueue();
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
// Create a source node to play the buffer
|
|
1136
|
+
const source = this._mic.audioContext.createBufferSource();
|
|
1137
|
+
source.buffer = audioBuffer;
|
|
1138
|
+
source.connect(this._mic.analyzer);
|
|
1139
|
+
source.connect(this._mic.audioContext.destination);
|
|
1140
|
+
// Schedule the chunk to play exactly when the previous chunk finishes
|
|
1141
|
+
const currentTime = this._mic.audioContext.currentTime;
|
|
1142
|
+
if (this._nextPlayTime < currentTime) {
|
|
1143
|
+
this._nextPlayTime = currentTime; // Reset if the queue has emptied
|
|
1144
|
+
}
|
|
1145
|
+
source.start(this._nextPlayTime);
|
|
1146
|
+
this._sources.push(source); // keep instance of source to stop later
|
|
1147
|
+
this._nextPlayTime += audioBuffer.duration;
|
|
1148
|
+
source.onended = () => {
|
|
1149
|
+
this._sources.shift();
|
|
1150
|
+
if (!this._sources.length)
|
|
1151
|
+
if (!this._config.orb?.mode || this._config.orb?.mode === 'auto')
|
|
1152
|
+
this._orb.setState('initialized'); // reset
|
|
1153
|
+
};
|
|
1154
|
+
if (!this._config.orb?.mode || this._config.orb?.mode === 'auto')
|
|
1155
|
+
this._orb.setState('speaking'); // speaking
|
|
1156
|
+
}
|
|
977
1157
|
/**
|
|
978
1158
|
* Play on sound
|
|
979
1159
|
*/
|
|
@@ -990,11 +1170,21 @@ class SpeakerService {
|
|
|
990
1170
|
return;
|
|
991
1171
|
this._downSound.play();
|
|
992
1172
|
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Clear playback queue
|
|
1175
|
+
*/
|
|
1176
|
+
_clearQueue() {
|
|
1177
|
+
this._sources.forEach((s) => s.stop());
|
|
1178
|
+
this._sources = [];
|
|
1179
|
+
this._nextPlayTime = 0;
|
|
1180
|
+
}
|
|
993
1181
|
/**
|
|
994
1182
|
* Load subscriptions
|
|
995
1183
|
*/
|
|
996
1184
|
_loadSubscriptions() {
|
|
997
|
-
this._subs.sink = this._event.wakeword
|
|
1185
|
+
this._subs.sink = this._event.wakeword
|
|
1186
|
+
.pipe(throttleTime(this._config.throttleTime))
|
|
1187
|
+
.subscribe(() => {
|
|
998
1188
|
this.playUp();
|
|
999
1189
|
});
|
|
1000
1190
|
// If default, on silence, play down
|
|
@@ -1002,6 +1192,10 @@ class SpeakerService {
|
|
|
1002
1192
|
if (!ev.interimResponse)
|
|
1003
1193
|
this.playDown();
|
|
1004
1194
|
});
|
|
1195
|
+
// If recording event
|
|
1196
|
+
this._subs.sink = this._event.recording.subscribe(() => {
|
|
1197
|
+
this._clearQueue();
|
|
1198
|
+
});
|
|
1005
1199
|
}
|
|
1006
1200
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.16", ngImport: i0, type: SpeakerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
1007
1201
|
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.16", ngImport: i0, type: SpeakerService }); }
|
|
@@ -1022,6 +1216,7 @@ class BridgeService {
|
|
|
1022
1216
|
this.model = inject(ModelService);
|
|
1023
1217
|
this.pipeline = inject(PipelineService);
|
|
1024
1218
|
this.platform = inject(PlatformService);
|
|
1219
|
+
this.orbComponentService = inject(OrbComponentService);
|
|
1025
1220
|
}
|
|
1026
1221
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.16", ngImport: i0, type: BridgeService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
1027
1222
|
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.16", ngImport: i0, type: BridgeService }); }
|
|
@@ -1035,208 +1230,393 @@ class OrbComponent {
|
|
|
1035
1230
|
this._config = inject(ConfigService);
|
|
1036
1231
|
this._platform = inject(PlatformService);
|
|
1037
1232
|
this._audio = inject(AudioService);
|
|
1233
|
+
this._mic = inject(MicrophoneService);
|
|
1038
1234
|
this._event = inject(EventService);
|
|
1235
|
+
this._ngZone = inject(NgZone);
|
|
1236
|
+
this._service = inject(OrbComponentService);
|
|
1039
1237
|
this._subs = new SubSink();
|
|
1040
|
-
this.
|
|
1041
|
-
this.
|
|
1042
|
-
|
|
1043
|
-
this.
|
|
1044
|
-
this.
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1238
|
+
this._clock = new THREE.Clock();
|
|
1239
|
+
this.micVolume = 0;
|
|
1240
|
+
// State Management
|
|
1241
|
+
this.currentState = 'idle';
|
|
1242
|
+
this.agentProfiles = {
|
|
1243
|
+
idle: {
|
|
1244
|
+
spike: 0.05,
|
|
1245
|
+
noiseScale: 1.0,
|
|
1246
|
+
speed: 0.2,
|
|
1247
|
+
twist: 0.0,
|
|
1248
|
+
pulse: 0.0,
|
|
1249
|
+
base: '#001133',
|
|
1250
|
+
peak: '#ff4000',
|
|
1251
|
+
},
|
|
1252
|
+
initialized: {
|
|
1253
|
+
spike: 0.05,
|
|
1254
|
+
noiseScale: 1.0,
|
|
1255
|
+
speed: 0.2,
|
|
1256
|
+
twist: 0.0,
|
|
1257
|
+
pulse: 0.0,
|
|
1258
|
+
base: '#001133',
|
|
1259
|
+
peak: '#00aaff',
|
|
1260
|
+
},
|
|
1261
|
+
listening: {
|
|
1262
|
+
spike: 0.2,
|
|
1263
|
+
noiseScale: 2.5,
|
|
1264
|
+
speed: 1.0,
|
|
1265
|
+
twist: 0.0,
|
|
1266
|
+
pulse: 0.0,
|
|
1267
|
+
base: '#002211',
|
|
1268
|
+
peak: '#00ff88',
|
|
1269
|
+
},
|
|
1270
|
+
thinking: {
|
|
1271
|
+
spike: 0.2,
|
|
1272
|
+
noiseScale: 1.5,
|
|
1273
|
+
speed: 1.5,
|
|
1274
|
+
twist: 1.5,
|
|
1275
|
+
pulse: 0.0,
|
|
1276
|
+
base: '#220033',
|
|
1277
|
+
peak: '#ff00ff',
|
|
1278
|
+
},
|
|
1279
|
+
speaking: {
|
|
1280
|
+
spike: 0.1,
|
|
1281
|
+
noiseScale: 1.0,
|
|
1282
|
+
speed: 0.5,
|
|
1283
|
+
twist: 0.0,
|
|
1284
|
+
pulse: 0.1,
|
|
1285
|
+
base: '#331100',
|
|
1286
|
+
peak: '#ff8800',
|
|
1287
|
+
},
|
|
1288
|
+
};
|
|
1289
|
+
this.targets = { ...this.agentProfiles.idle };
|
|
1290
|
+
this.targetColorBase = new THREE.Color(this.targets.base);
|
|
1291
|
+
this.targetColorPeak = new THREE.Color(this.targets.peak);
|
|
1049
1292
|
/**
|
|
1050
|
-
*
|
|
1293
|
+
* Animator
|
|
1051
1294
|
*/
|
|
1052
1295
|
this._animate = () => {
|
|
1053
|
-
this.
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
// but it won't "run away" as performance.now() grows.
|
|
1061
|
-
const speedFactor = 1 + this.currentIntensity / 20;
|
|
1062
|
-
this.elapsedTime += delta * speedFactor;
|
|
1063
|
-
// 4. Rotation (Constant per frame, scaled by intensity)
|
|
1064
|
-
this.orb.rotation.y += 0.005 + this.currentIntensity / 5000;
|
|
1065
|
-
this.orb.rotation.z += 0.002;
|
|
1066
|
-
// 5. Vertex Displacement
|
|
1067
|
-
const positionAttribute = this.orb.geometry.getAttribute('position');
|
|
1068
|
-
for (let i = 0; i < positionAttribute.count; i++) {
|
|
1069
|
-
const ix = i * 3;
|
|
1070
|
-
const iy = i * 3 + 1;
|
|
1071
|
-
const iz = i * 3 + 2;
|
|
1072
|
-
const x = this.originalVertices[ix];
|
|
1073
|
-
const y = this.originalVertices[iy];
|
|
1074
|
-
const z = this.originalVertices[iz];
|
|
1075
|
-
// We use this.elapsedTime instead of performance.now()
|
|
1076
|
-
// This creates a stable frequency regardless of how long the app has been open
|
|
1077
|
-
const wave = Math.sin(x * 2 + this.elapsedTime) *
|
|
1078
|
-
Math.cos(y * 2 + this.elapsedTime) *
|
|
1079
|
-
(this.currentIntensity / 300);
|
|
1080
|
-
const currentRadius = Math.sqrt(x * x + y * y + z * z) + wave;
|
|
1081
|
-
const finalScale = Math.min(currentRadius, 2.5) / 1.5;
|
|
1082
|
-
positionAttribute.setXYZ(i, x * finalScale, y * finalScale, z * finalScale);
|
|
1296
|
+
this._animationFrameId = requestAnimationFrame(this._animate);
|
|
1297
|
+
const elapsedTime = this._clock.getElapsedTime();
|
|
1298
|
+
this._material.uniforms['uTime'].value = elapsedTime;
|
|
1299
|
+
let dynamicSpike = this.targets.spike;
|
|
1300
|
+
let dynamicPulse = this.targets.pulse;
|
|
1301
|
+
if (this.currentState === 'listening') {
|
|
1302
|
+
dynamicSpike += this.micVolume * 1.0;
|
|
1083
1303
|
}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
this.
|
|
1304
|
+
else if (this.currentState === 'speaking') {
|
|
1305
|
+
const ttsVolume = this._getTTSVolume();
|
|
1306
|
+
dynamicPulse += ttsVolume * 0.5;
|
|
1307
|
+
}
|
|
1308
|
+
const lerpFactor = 0.08;
|
|
1309
|
+
this._material.uniforms['uSpike'].value +=
|
|
1310
|
+
(dynamicSpike - this._material.uniforms['uSpike'].value) * lerpFactor;
|
|
1311
|
+
this._material.uniforms['uPulse'].value +=
|
|
1312
|
+
(dynamicPulse - this._material.uniforms['uPulse'].value) * lerpFactor;
|
|
1313
|
+
this._material.uniforms['uNoiseScale'].value +=
|
|
1314
|
+
(this.targets.noiseScale - this._material.uniforms['uNoiseScale'].value) * lerpFactor;
|
|
1315
|
+
this._material.uniforms['uSpeed'].value +=
|
|
1316
|
+
(this.targets.speed - this._material.uniforms['uSpeed'].value) * lerpFactor;
|
|
1317
|
+
this._material.uniforms['uTwist'].value +=
|
|
1318
|
+
(this.targets.twist - this._material.uniforms['uTwist'].value) * lerpFactor;
|
|
1319
|
+
this._material.uniforms['uColorBase'].value.lerp(this.targetColorBase, lerpFactor);
|
|
1320
|
+
this._material.uniforms['uColorPeak'].value.lerp(this.targetColorPeak, lerpFactor);
|
|
1321
|
+
this._sphere.rotation.y = elapsedTime * 0.1;
|
|
1322
|
+
this._sphere.rotation.z = elapsedTime * 0.01;
|
|
1323
|
+
this._renderer.render(this._scene, this._camera);
|
|
1090
1324
|
};
|
|
1091
1325
|
}
|
|
1092
|
-
get isRecording() {
|
|
1093
|
-
return this._audio.isRecording;
|
|
1094
|
-
}
|
|
1095
1326
|
get orbSize() {
|
|
1096
1327
|
return this._config.orb?.size ?? 400;
|
|
1097
1328
|
}
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
*
|
|
1101
|
-
* @param color
|
|
1102
|
-
* @param emissive
|
|
1103
|
-
*/
|
|
1104
|
-
changeColor(color, emissive) {
|
|
1105
|
-
const material = this.orb.material;
|
|
1106
|
-
material.color = color;
|
|
1107
|
-
material.emissive = emissive;
|
|
1108
|
-
}
|
|
1109
|
-
/**
|
|
1110
|
-
* Toggle recording
|
|
1111
|
-
*/
|
|
1112
|
-
toggleRecording() {
|
|
1113
|
-
this._audio.toggleRecording();
|
|
1329
|
+
get isMuted() {
|
|
1330
|
+
return this._mic.isMuted;
|
|
1114
1331
|
}
|
|
1115
|
-
|
|
1332
|
+
ngAfterViewInit() {
|
|
1116
1333
|
if (this._platform.isServer)
|
|
1117
1334
|
return;
|
|
1118
|
-
this.
|
|
1119
|
-
this._animate();
|
|
1335
|
+
this.dataArray = new Uint8Array(this._mic.analyzer.frequencyBinCount);
|
|
1120
1336
|
this._loadSubscribers();
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
this.targetIntensity = changes['intensity'].currentValue;
|
|
1127
|
-
}
|
|
1337
|
+
// MUST run outside Angular to prevent CD loops
|
|
1338
|
+
this._ngZone.runOutsideAngular(() => {
|
|
1339
|
+
this._initThreeJs();
|
|
1340
|
+
this._animate();
|
|
1341
|
+
});
|
|
1128
1342
|
}
|
|
1129
1343
|
ngOnDestroy() {
|
|
1130
1344
|
if (this._platform.isServer)
|
|
1131
1345
|
return;
|
|
1132
|
-
cancelAnimationFrame(this.
|
|
1133
|
-
this.renderer.dispose();
|
|
1134
|
-
this.orb.geometry.dispose();
|
|
1135
|
-
this.orb.material.dispose();
|
|
1346
|
+
cancelAnimationFrame(this._animationFrameId);
|
|
1136
1347
|
this._subs.unsubscribe();
|
|
1348
|
+
if (this._renderer) {
|
|
1349
|
+
this._renderer.dispose();
|
|
1350
|
+
this._renderer.forceContextLoss();
|
|
1351
|
+
// Remove canvas from DOM to ensure cleanup
|
|
1352
|
+
const domElement = this._renderer.domElement;
|
|
1353
|
+
if (domElement && domElement.parentNode) {
|
|
1354
|
+
domElement.parentNode.removeChild(domElement);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
if (this._geometry)
|
|
1358
|
+
this._geometry.dispose();
|
|
1359
|
+
if (this._material)
|
|
1360
|
+
this._material.dispose();
|
|
1137
1361
|
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Space bar press
|
|
1364
|
+
*/
|
|
1138
1365
|
handleSpacebarPress(event) {
|
|
1139
|
-
event.
|
|
1366
|
+
if (event.code !== this._config.hotkey)
|
|
1367
|
+
return;
|
|
1368
|
+
event.preventDefault();
|
|
1140
1369
|
this.toggleRecording();
|
|
1141
1370
|
}
|
|
1142
1371
|
/**
|
|
1143
|
-
*
|
|
1372
|
+
* Toggle recording
|
|
1144
1373
|
*/
|
|
1145
|
-
|
|
1146
|
-
this.
|
|
1147
|
-
|
|
1148
|
-
this.
|
|
1149
|
-
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
1150
|
-
this.renderer.setPixelRatio(window.devicePixelRatio);
|
|
1151
|
-
this.rendererContainer.nativeElement.appendChild(this.renderer.domElement);
|
|
1152
|
-
// Initial resize to fit container
|
|
1153
|
-
this._resize();
|
|
1154
|
-
// Orb Geometry (Icosahedron for organic detail)
|
|
1155
|
-
const geometry = new THREE.IcosahedronGeometry(1.2, 32);
|
|
1156
|
-
this.originalVertices = geometry.attributes['position'].array.slice();
|
|
1157
|
-
const material = new THREE.MeshStandardMaterial({
|
|
1158
|
-
color: 'red',
|
|
1159
|
-
wireframe: true,
|
|
1160
|
-
transparent: true,
|
|
1161
|
-
opacity: 0.6,
|
|
1162
|
-
emissive: 'red',
|
|
1163
|
-
emissiveIntensity: 0.5,
|
|
1164
|
-
});
|
|
1165
|
-
this.orb = new THREE.Mesh(geometry, material);
|
|
1166
|
-
this.scene.add(this.orb);
|
|
1167
|
-
const light = new THREE.PointLight(0xffffff, 15, 10);
|
|
1168
|
-
light.position.set(2, 2, 2);
|
|
1169
|
-
this.scene.add(light);
|
|
1170
|
-
this.scene.add(new THREE.AmbientLight(0x404040));
|
|
1374
|
+
toggleRecording() {
|
|
1375
|
+
if (this.isMuted)
|
|
1376
|
+
return;
|
|
1377
|
+
this._audio.toggleRecording();
|
|
1171
1378
|
}
|
|
1172
1379
|
/**
|
|
1173
|
-
*
|
|
1380
|
+
* Set state of orb
|
|
1381
|
+
*
|
|
1382
|
+
* @param state orb state
|
|
1174
1383
|
*/
|
|
1175
|
-
|
|
1176
|
-
const
|
|
1177
|
-
|
|
1178
|
-
this.
|
|
1179
|
-
this.
|
|
1180
|
-
this.
|
|
1384
|
+
setState(state) {
|
|
1385
|
+
const profile = this.agentProfiles[state];
|
|
1386
|
+
this.currentState = state;
|
|
1387
|
+
this.targets = { ...profile };
|
|
1388
|
+
this.targetColorBase.set(profile.base);
|
|
1389
|
+
this.targetColorPeak.set(profile.peak);
|
|
1181
1390
|
}
|
|
1182
1391
|
/**
|
|
1183
1392
|
* Subscriptions
|
|
1184
1393
|
*/
|
|
1185
1394
|
_loadSubscribers() {
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
if (this._isActive)
|
|
1189
|
-
this.targetIntensity = e.dbNormalized * 100;
|
|
1190
|
-
else
|
|
1191
|
-
this.targetIntensity = 0;
|
|
1192
|
-
});
|
|
1193
|
-
// Wake word event
|
|
1194
|
-
this._subs.sink = this._event.wakeword.subscribe(() => {
|
|
1195
|
-
this.changeColor(new THREE.Color(0x00d2ff), new THREE.Color(0x0066ff));
|
|
1196
|
-
this._isActive = true;
|
|
1395
|
+
this._subs.sink = this._event.speech.subscribe((data) => {
|
|
1396
|
+
this.micVolume = this.isMuted ? 0 : data.dbNormalized;
|
|
1197
1397
|
});
|
|
1198
|
-
//
|
|
1199
|
-
this.
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
this.
|
|
1203
|
-
|
|
1204
|
-
|
|
1398
|
+
// only update orb state if mode is auto
|
|
1399
|
+
if (!this._config.orb?.mode || this._config.orb?.mode === 'auto') {
|
|
1400
|
+
// after wakeword, set to listening
|
|
1401
|
+
this._subs.sink = this._event.wakeword.subscribe(() => {
|
|
1402
|
+
this.setState('listening');
|
|
1403
|
+
});
|
|
1404
|
+
// after silence, set thinking or idle
|
|
1405
|
+
this._subs.sink = this._event.silence.subscribe((ev) => {
|
|
1406
|
+
if (ev.interimResponse)
|
|
1407
|
+
this.setState('thinking');
|
|
1408
|
+
else
|
|
1409
|
+
this.setState('idle');
|
|
1410
|
+
});
|
|
1411
|
+
// if recording started
|
|
1412
|
+
this._subs.sink = this._event.recording.subscribe(() => {
|
|
1413
|
+
this.setState('listening');
|
|
1414
|
+
});
|
|
1415
|
+
// state change using service
|
|
1416
|
+
this._subs.sink = this._service.state.subscribe((state) => {
|
|
1417
|
+
this.setState(state);
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Speech volume for animation
|
|
1423
|
+
*/
|
|
1424
|
+
_getTTSVolume() {
|
|
1425
|
+
this._mic.analyzer.getByteFrequencyData(this.dataArray);
|
|
1426
|
+
const sum = this.dataArray.reduce((a, b) => a + b, 0);
|
|
1427
|
+
return sum / this.dataArray.length / 255.0;
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Init
|
|
1431
|
+
*/
|
|
1432
|
+
_initThreeJs() {
|
|
1433
|
+
const size = this.orbSize;
|
|
1434
|
+
this._scene = new THREE.Scene();
|
|
1435
|
+
this._camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000);
|
|
1436
|
+
this._camera.position.z = 6;
|
|
1437
|
+
this._renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
1438
|
+
this._renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
1439
|
+
this._renderer.setSize(size, size);
|
|
1440
|
+
this._rendererContainer.nativeElement.appendChild(this._renderer.domElement);
|
|
1441
|
+
const particlesCount = this._config.orb?.particlesCount ?? 30000;
|
|
1442
|
+
this._geometry = new THREE.BufferGeometry();
|
|
1443
|
+
const posArray = new Float32Array(particlesCount * 3);
|
|
1444
|
+
const randomArray = new Float32Array(particlesCount);
|
|
1445
|
+
const radius = this._config.orb?.radius ?? 1.8;
|
|
1446
|
+
for (let i = 0; i < particlesCount; i++) {
|
|
1447
|
+
const phi = Math.acos(-1 + (2 * i) / particlesCount);
|
|
1448
|
+
const theta = Math.sqrt(particlesCount * Math.PI) * phi;
|
|
1449
|
+
posArray[i * 3] = radius * Math.cos(theta) * Math.sin(phi);
|
|
1450
|
+
posArray[i * 3 + 1] = radius * Math.sin(theta) * Math.sin(phi);
|
|
1451
|
+
posArray[i * 3 + 2] = radius * Math.cos(phi);
|
|
1452
|
+
randomArray[i] = Math.random();
|
|
1453
|
+
}
|
|
1454
|
+
this._geometry.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
|
|
1455
|
+
this._geometry.setAttribute('aRandom', new THREE.BufferAttribute(randomArray, 1));
|
|
1456
|
+
this._material = new THREE.ShaderMaterial({
|
|
1457
|
+
uniforms: {
|
|
1458
|
+
uTime: { value: 0 },
|
|
1459
|
+
uSpike: { value: 0.05 },
|
|
1460
|
+
uNoiseScale: { value: 1.0 },
|
|
1461
|
+
uSpeed: { value: 0.2 },
|
|
1462
|
+
uTwist: { value: 0.0 },
|
|
1463
|
+
uPulse: { value: 0.0 },
|
|
1464
|
+
uColorBase: { value: new THREE.Color('#002244') },
|
|
1465
|
+
uColorPeak: { value: new THREE.Color('#00ffff') },
|
|
1466
|
+
},
|
|
1467
|
+
vertexShader: this._getVertexShader(),
|
|
1468
|
+
fragmentShader: this._getFragmentShader(),
|
|
1469
|
+
transparent: true,
|
|
1470
|
+
blending: THREE.AdditiveBlending,
|
|
1471
|
+
depthWrite: false,
|
|
1205
1472
|
});
|
|
1473
|
+
this._sphere = new THREE.Points(this._geometry, this._material);
|
|
1474
|
+
this._scene.add(this._sphere);
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Vertex shader
|
|
1478
|
+
*/
|
|
1479
|
+
_getVertexShader() {
|
|
1480
|
+
return `
|
|
1481
|
+
uniform float uTime;
|
|
1482
|
+
uniform float uSpike;
|
|
1483
|
+
uniform float uNoiseScale;
|
|
1484
|
+
uniform float uSpeed;
|
|
1485
|
+
uniform float uTwist;
|
|
1486
|
+
uniform float uPulse;
|
|
1487
|
+
|
|
1488
|
+
varying vec3 vColor;
|
|
1489
|
+
varying float vDisplacement;
|
|
1490
|
+
|
|
1491
|
+
attribute float aRandom;
|
|
1492
|
+
|
|
1493
|
+
// Simplex 3D Noise
|
|
1494
|
+
vec4 permute(vec4 x){return mod(((x*34.0)+1.0)*x, 289.0);}
|
|
1495
|
+
vec4 taylorInvSqrt(vec4 r){return 1.79284291400159 - 0.85373472095314 * r;}
|
|
1496
|
+
float snoise(vec3 v){
|
|
1497
|
+
const vec2 C = vec2(1.0/6.0, 1.0/3.0);
|
|
1498
|
+
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
|
|
1499
|
+
vec3 i = floor(v + dot(v, C.yyy) );
|
|
1500
|
+
vec3 x0 = v - i + dot(i, C.xxx) ;
|
|
1501
|
+
vec3 g = step(x0.yzx, x0.xyz);
|
|
1502
|
+
vec3 l = 1.0 - g;
|
|
1503
|
+
vec3 i1 = min( g.xyz, l.zxy );
|
|
1504
|
+
vec3 i2 = max( g.xyz, l.zxy );
|
|
1505
|
+
vec3 x1 = x0 - i1 + 1.0 * C.xxx;
|
|
1506
|
+
vec3 x2 = x0 - i2 + 2.0 * C.xxx;
|
|
1507
|
+
vec3 x3 = x0 - 1.0 + 3.0 * C.xxx;
|
|
1508
|
+
i = mod(i, 289.0 );
|
|
1509
|
+
vec4 p = permute( permute( permute( i.z + vec4(0.0, i1.z, i2.z, 1.0 )) + i.y + vec4(0.0, i1.y, i2.y, 1.0 )) + i.x + vec4(0.0, i1.x, i2.x, 1.0 ));
|
|
1510
|
+
float n_ = 1.0/7.0;
|
|
1511
|
+
vec3 ns = n_ * D.wyz - D.xzx;
|
|
1512
|
+
vec4 j = p - 49.0 * floor(p * ns.z *ns.z);
|
|
1513
|
+
vec4 x_ = floor(j * ns.z);
|
|
1514
|
+
vec4 y_ = floor(j - 7.0 * x_ );
|
|
1515
|
+
vec4 x = x_ *ns.x + ns.yyyy;
|
|
1516
|
+
vec4 y = y_ *ns.x + ns.yyyy;
|
|
1517
|
+
vec4 h = 1.0 - abs(x) - abs(y);
|
|
1518
|
+
vec4 b0 = vec4( x.xy, y.xy );
|
|
1519
|
+
vec4 b1 = vec4( x.zw, y.zw );
|
|
1520
|
+
vec4 s0 = floor(b0)*2.0 + 1.0;
|
|
1521
|
+
vec4 s1 = floor(b1)*2.0 + 1.0;
|
|
1522
|
+
vec4 sh = -step(h, vec4(0.0));
|
|
1523
|
+
vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy ;
|
|
1524
|
+
vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww ;
|
|
1525
|
+
vec3 p0 = vec3(a0.xy,h.x);
|
|
1526
|
+
vec3 p1 = vec3(a0.zw,h.y);
|
|
1527
|
+
vec3 p2 = vec3(a1.xy,h.z);
|
|
1528
|
+
vec3 p3 = vec3(a1.zw,h.w);
|
|
1529
|
+
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2, p2), dot(p3,p3)));
|
|
1530
|
+
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
|
|
1531
|
+
vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
|
|
1532
|
+
m = m * m;
|
|
1533
|
+
return 42.0 * dot( m*m, vec4( dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3) ) );
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
void main() {
|
|
1537
|
+
vec3 pos = position;
|
|
1538
|
+
float time = uTime * uSpeed;
|
|
1539
|
+
|
|
1540
|
+
float angle = pos.y * uTwist;
|
|
1541
|
+
mat2 rot = mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
|
|
1542
|
+
pos.xz *= rot;
|
|
1543
|
+
|
|
1544
|
+
vec3 normal = normalize(pos);
|
|
1545
|
+
float noise = snoise(pos * uNoiseScale + time);
|
|
1546
|
+
|
|
1547
|
+
float totalDisplacement = (noise * uSpike) + uPulse;
|
|
1548
|
+
pos += normal * totalDisplacement;
|
|
1549
|
+
|
|
1550
|
+
vDisplacement = totalDisplacement;
|
|
1551
|
+
|
|
1552
|
+
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
|
|
1553
|
+
|
|
1554
|
+
gl_PointSize = (15.0 + totalDisplacement * 20.0) * (1.0 / -mvPosition.z);
|
|
1555
|
+
gl_PointSize *= (1.0 + sin(uTime * 5.0 + aRandom * 50.0) * 0.2);
|
|
1556
|
+
|
|
1557
|
+
gl_Position = projectionMatrix * mvPosition;
|
|
1558
|
+
}
|
|
1559
|
+
`;
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Fragment shader
|
|
1563
|
+
*/
|
|
1564
|
+
_getFragmentShader() {
|
|
1565
|
+
return `
|
|
1566
|
+
varying float vDisplacement;
|
|
1567
|
+
uniform vec3 uColorBase;
|
|
1568
|
+
uniform vec3 uColorPeak;
|
|
1569
|
+
|
|
1570
|
+
void main() {
|
|
1571
|
+
float dist = distance(gl_PointCoord, vec2(0.5));
|
|
1572
|
+
if (dist > 0.5) discard;
|
|
1573
|
+
float alpha = 1.0 - pow(dist * 2.0, 2.0);
|
|
1574
|
+
|
|
1575
|
+
float mixValue = smoothstep(-0.2, 0.5, vDisplacement);
|
|
1576
|
+
vec3 finalColor = mix(uColorBase, uColorPeak, mixValue);
|
|
1577
|
+
|
|
1578
|
+
gl_FragColor = vec4(finalColor, alpha * 0.9);
|
|
1579
|
+
}
|
|
1580
|
+
`;
|
|
1206
1581
|
}
|
|
1207
1582
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.16", ngImport: i0, type: OrbComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
1208
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.16", type: OrbComponent, isStandalone: true, selector: "app-orb-component", host: { listeners: { "window:keydown
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1583
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.16", type: OrbComponent, isStandalone: true, selector: "app-orb-component", host: { listeners: { "window:keydown": "handleSpacebarPress($event)" } }, viewQueries: [{ propertyName: "_rendererContainer", first: true, predicate: ["rendererContainer"], descendants: true, static: true }], ngImport: i0, template: `
|
|
1584
|
+
<div
|
|
1585
|
+
#rendererContainer
|
|
1586
|
+
role="button"
|
|
1587
|
+
tabindex="0"
|
|
1588
|
+
class="orb-container"
|
|
1589
|
+
[style.width.px]="orbSize"
|
|
1590
|
+
[style.height.px]="orbSize"
|
|
1591
|
+
(click)="toggleRecording()"
|
|
1592
|
+
(keypress)="toggleRecording()"
|
|
1593
|
+
[ngClass]="{ muted: isMuted }"
|
|
1594
|
+
></div>
|
|
1595
|
+
`, isInline: true, styles: [".orb-container{display:flex;justify-content:center;align-items:center;overflow:hidden;cursor:pointer}.muted{cursor:not-allowed}canvas{display:block;outline:none}\n"], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }] }); }
|
|
1218
1596
|
}
|
|
1219
1597
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.16", ngImport: i0, type: OrbComponent, decorators: [{
|
|
1220
1598
|
type: Component,
|
|
1221
|
-
args: [{ selector: 'app-orb-component',
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1599
|
+
args: [{ selector: 'app-orb-component', imports: [NgClass], template: `
|
|
1600
|
+
<div
|
|
1601
|
+
#rendererContainer
|
|
1602
|
+
role="button"
|
|
1603
|
+
tabindex="0"
|
|
1604
|
+
class="orb-container"
|
|
1605
|
+
[style.width.px]="orbSize"
|
|
1606
|
+
[style.height.px]="orbSize"
|
|
1607
|
+
(click)="toggleRecording()"
|
|
1608
|
+
(keypress)="toggleRecording()"
|
|
1609
|
+
[ngClass]="{ muted: isMuted }"
|
|
1610
|
+
></div>
|
|
1611
|
+
`, styles: [".orb-container{display:flex;justify-content:center;align-items:center;overflow:hidden;cursor:pointer}.muted{cursor:not-allowed}canvas{display:block;outline:none}\n"] }]
|
|
1612
|
+
}], propDecorators: { _rendererContainer: [{
|
|
1232
1613
|
type: ViewChild,
|
|
1233
1614
|
args: ['rendererContainer', { static: true }]
|
|
1234
1615
|
}], handleSpacebarPress: [{
|
|
1235
1616
|
type: HostListener,
|
|
1236
|
-
args: ['window:keydown
|
|
1617
|
+
args: ['window:keydown', ['$event']]
|
|
1237
1618
|
}] } });
|
|
1238
1619
|
|
|
1239
|
-
const DEFAULT_THROTTLE_TIME = 1000;
|
|
1240
1620
|
class WakeyWakeyComponent {
|
|
1241
1621
|
constructor() {
|
|
1242
1622
|
/**
|
|
@@ -1331,7 +1711,7 @@ class WakeyWakeyComponent {
|
|
|
1331
1711
|
});
|
|
1332
1712
|
// Wake word event
|
|
1333
1713
|
this._subs.sink = this._event.wakeword
|
|
1334
|
-
.pipe(throttleTime(this._config.throttleTime
|
|
1714
|
+
.pipe(throttleTime(this._config.throttleTime))
|
|
1335
1715
|
.subscribe((e) => {
|
|
1336
1716
|
this.wakeword.emit(e);
|
|
1337
1717
|
});
|
|
@@ -1392,6 +1772,7 @@ function provideWakeyWakey(config) {
|
|
|
1392
1772
|
SpeechRecognitionService,
|
|
1393
1773
|
ModelService,
|
|
1394
1774
|
BridgeService,
|
|
1775
|
+
OrbComponentService,
|
|
1395
1776
|
provideAppInitializer(async () => {
|
|
1396
1777
|
const _config = inject(ConfigService);
|
|
1397
1778
|
const _model = inject(ModelService);
|