@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.
@@ -1,10 +1,10 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, inject, Injectable, PLATFORM_ID, HostListener, ViewChild, Component, EventEmitter, Output, provideAppInitializer } from '@angular/core';
3
- import { Subject, withLatestFrom, concatMap, filter, map, distinctUntilChanged, switchMap, EMPTY, tap, ignoreElements, timer, merge, take, throttleTime, share, delay, takeUntil, scan } from 'rxjs';
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 || '/wakeywakey';
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: { deviceId: { exact: deviceId }, noiseSuppression: false, echoCancellation: false },
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
- const source = this._audioContext.createMediaStreamSource(this._stream);
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
- source.connect(rnnoiseNode);
603
+ this._source.connect(rnnoiseNode);
528
604
  rnnoiseNode.connect(gainNode);
529
605
  }
530
606
  else {
531
- source.connect(gainNode);
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
- vadScore: await this._vad.score(data.sample),
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 }) => score > (this._config.onnx.wakewordInferenceThreshold ?? DEFAULT_INFERENCE_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
- // --- TRIGGER 1: Wakeword ---
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
- // --- TRIGGER 2: Continuous VAD > THRESHOLD for 1 second ---
838
- const continuousVadTrigger$ = this._event.speech.pipe(map((s) => s.vadScore > VAD_THRESHOLD), filter(() => !this._isRecording && this._isInitialized), // Ignore and prevent background buffering if already recording
839
- distinctUntilChanged(), switchMap((isVoiceActive) => {
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
- const bufferedChunks = [];
844
- // 1. Accumulate audio chunks silently
845
- const buffer$ = this._event.speech.pipe(tap((s) => bufferedChunks.push(s.sample)), ignoreElements());
846
- // 2. Timer that emits the accumulated chunks after 1 second
847
- const timer$ = timer(300).pipe(map(() => bufferedChunks));
848
- // Merge both. If the timer fires, take(1) stops the buffer$ stream.
849
- // If isVoiceActive turns false before 1s, switchMap cancels both.
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
- // --- COMBINE TRIGGERS ---
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(1000));
857
- // --- MAIN RECORDING PIPELINE ---
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(tap((speech) => commandChunks.push(speech.sample)), share());
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
- // 1. Normal silence timeout logic
869
- const normalSilenceTimeout$ = silence$.pipe(delay(500), switchMap((isSilent) => {
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
- // 2. Force complete logic checking the variable
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.subscribe(() => {
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.originalVertices = null;
1041
- this.targetIntensity = 0;
1042
- this.currentIntensity = 0;
1043
- this.clock = new THREE.Timer();
1044
- this.elapsedTime = 0;
1045
- /**
1046
- * Should orb be reacting to speech
1047
- */
1048
- this._isActive = false;
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
- * Animate
1293
+ * Animator
1051
1294
  */
1052
1295
  this._animate = () => {
1053
- this.animationId = requestAnimationFrame(this._animate);
1054
- // 1. Get the time passed since the last frame (delta)
1055
- const delta = this.clock.getDelta();
1056
- // 2. Smoothly update currentIntensity
1057
- this.currentIntensity += (this.targetIntensity - this.currentIntensity) * 0.05;
1058
- // 3. Increment our own elapsedTime ticker.
1059
- // We multiply delta by intensity so the pulse speeds up when busy,
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
- positionAttribute.needsUpdate = true;
1085
- // Optional: Update material feedback based on intensity
1086
- const material = this.orb.material;
1087
- material.emissiveIntensity = 0.2 + this.currentIntensity / 100;
1088
- material.opacity = 0.3 + this.currentIntensity / 200;
1089
- this.renderer.render(this.scene, this.camera);
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
- * Change color or orb
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
- ngOnInit() {
1332
+ ngAfterViewInit() {
1116
1333
  if (this._platform.isServer)
1117
1334
  return;
1118
- this._init();
1119
- this._animate();
1335
+ this.dataArray = new Uint8Array(this._mic.analyzer.frequencyBinCount);
1120
1336
  this._loadSubscribers();
1121
- }
1122
- ngOnChanges(changes) {
1123
- if (this._platform.isServer)
1124
- return;
1125
- if (changes['intensity']) {
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.animationId);
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.preventDefault(); // Prevents the default space bar action (e.g., scrolling)
1366
+ if (event.code !== this._config.hotkey)
1367
+ return;
1368
+ event.preventDefault();
1140
1369
  this.toggleRecording();
1141
1370
  }
1142
1371
  /**
1143
- * Initialize
1372
+ * Toggle recording
1144
1373
  */
1145
- _init() {
1146
- this.scene = new THREE.Scene();
1147
- this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
1148
- this.camera.position.z = 3;
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
- * Resize container
1380
+ * Set state of orb
1381
+ *
1382
+ * @param state orb state
1174
1383
  */
1175
- _resize() {
1176
- const width = this.orbSize ?? this.rendererContainer.nativeElement.clientWidth;
1177
- const height = this.orbSize ?? this.rendererContainer.nativeElement.clientHeight;
1178
- this.renderer.setSize(width, height);
1179
- this.camera.aspect = width / height;
1180
- this.camera.updateProjectionMatrix();
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
- // Speech event
1187
- this._subs.sink = this._event.speech.subscribe((e) => {
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
- // Silence
1199
- this._subs.sink = this._event.silence.subscribe((ev) => {
1200
- if (!ev.interimResponse) {
1201
- // on silence, set color to red
1202
- this.changeColor(new THREE.Color('red'), new THREE.Color('red'));
1203
- this._isActive = false;
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.Space": "handleSpacebarPress($event)" } }, viewQueries: [{ propertyName: "rendererContainer", first: true, predicate: ["rendererContainer"], descendants: true, static: true }], usesOnChanges: true, ngImport: i0, template: `<div
1209
- #rendererContainer
1210
- tabindex="0"
1211
- role="button"
1212
- class="orb-viewport"
1213
- [style.height]="orbSize"
1214
- [style.width]="orbSize"
1215
- (click)="toggleRecording()"
1216
- (keypress)="toggleRecording()"
1217
- ></div>`, isInline: true, styles: [".orb-viewport{background:transparent;cursor:pointer}\n"] }); }
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', standalone: true, template: `<div
1222
- #rendererContainer
1223
- tabindex="0"
1224
- role="button"
1225
- class="orb-viewport"
1226
- [style.height]="orbSize"
1227
- [style.width]="orbSize"
1228
- (click)="toggleRecording()"
1229
- (keypress)="toggleRecording()"
1230
- ></div>`, styles: [".orb-viewport{background:transparent;cursor:pointer}\n"] }]
1231
- }], propDecorators: { rendererContainer: [{
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.Space', ['$event']]
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 ?? DEFAULT_THROTTLE_TIME))
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);