From 7b2054eb3da222a17e0a3277e972951c0ddf09f0 Mon Sep 17 00:00:00 2001 From: Wassim CHEGHAM Date: Fri, 20 Apr 2018 00:47:38 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20eaaaaaaaaaaaaster=20egg=20=F0=9F=98=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + src/app/app.component.css | 8 ++ src/app/app.component.html | 3 +- src/app/app.component.ts | 14 +- src/app/core/algolia/algolia.module.spec.ts | 13 -- src/app/core/algolia/algolia.module.ts | 1 - src/app/core/core.module.ts | 8 ++ src/app/core/easteregg.service.ts | 65 +++++++++ src/app/core/nlp/inject-tokens.ts | 2 + src/app/core/nlp/nlp.module.ts | 25 ++++ src/app/core/nlp/nlp.service.ts | 27 ++++ src/app/core/nlp/speech-to-text.service.ts | 124 ++++++++++++++++++ src/app/core/nlp/text-to-speech.service.ts | 36 +++++ .../search-ui/search/search.component.html | 3 + src/app/search-ui/search/search.component.ts | 3 + src/environments/environment.prod.ts | 3 + src/environments/environment.ts | 3 + yarn.lock | 4 + 18 files changed, 327 insertions(+), 16 deletions(-) delete mode 100644 src/app/core/algolia/algolia.module.spec.ts create mode 100644 src/app/core/easteregg.service.ts create mode 100644 src/app/core/nlp/inject-tokens.ts create mode 100644 src/app/core/nlp/nlp.module.ts create mode 100644 src/app/core/nlp/nlp.service.ts create mode 100644 src/app/core/nlp/speech-to-text.service.ts create mode 100644 src/app/core/nlp/text-to-speech.service.ts diff --git a/package.json b/package.json index 1d7c96f..b2c5912 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@angular/router": "^6.0.0-rc.1", "algoliasearch": "^3.26.0", "algoliasearch-helper": "^2.24.0", + "api-ai-javascript": "^2.0.0-beta.21", "core-js": "^2.5.4", "hammerjs": "^2.0.8", "rxjs": "^6.0.0-rc.0", diff --git a/src/app/app.component.css b/src/app/app.component.css index 6b4fc29..a274103 100644 --- a/src/app/app.component.css +++ b/src/app/app.component.css @@ -2,3 +2,11 @@ margin-bottom: 30px; display: block; } + +.easter { + display: block; + height: 140px; + width: 100%; + position: absolute; + top: 0; +} \ No newline at end of file diff --git a/src/app/app.component.html b/src/app/app.component.html index 0680b43..740643b 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1 +1,2 @@ - +
+ \ No newline at end of file diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 7b0f672..4f1a559 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,5 @@ import { Component } from '@angular/core'; +import { EastereggService } from '@app/core/easteregg.service'; @Component({ selector: 'app-root', @@ -6,5 +7,16 @@ import { Component } from '@angular/core'; styleUrls: ['./app.component.css'] }) export class AppComponent { - title = 'app'; + hits = 0; + constructor(private easter: EastereggService) {} + + hitMe() { + this.hits++; + if (this.hits > 5) { + this.hits = 0; + this.easter.surprise(); + } else { + console.log('One more time...'); + } + } } diff --git a/src/app/core/algolia/algolia.module.spec.ts b/src/app/core/algolia/algolia.module.spec.ts deleted file mode 100644 index cfcee04..0000000 --- a/src/app/core/algolia/algolia.module.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AlgoliaModule } from './algolia.module'; - -describe('AlgoliaModule', () => { - let algoliaModule: AlgoliaModule; - - beforeEach(() => { - algoliaModule = new AlgoliaModule(); - }); - - it('should create an instance', () => { - expect(algoliaModule).toBeTruthy(); - }); -}); diff --git a/src/app/core/algolia/algolia.module.ts b/src/app/core/algolia/algolia.module.ts index b7393c0..0329dc2 100644 --- a/src/app/core/algolia/algolia.module.ts +++ b/src/app/core/algolia/algolia.module.ts @@ -10,7 +10,6 @@ export interface AlgoliaConfiguration { } @NgModule({ - imports: [CommonModule], providers: [AlgoliaService] }) export class AlgoliaModule { diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index faca996..561f3fa 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -1,7 +1,11 @@ +import { EastereggService } from './easteregg.service'; import { environment } from './../../environments/environment'; import { NgModule } from '@angular/core'; import { MaterialModule } from '@app/core/material/material.module'; import { AlgoliaModule } from '@app/core/algolia/algolia.module'; +import { TextToSpeechService } from '@app/core/nlp/text-to-speech.service'; +import { SpeechToTextService } from '@app/core/nlp/speech-to-text.service'; +import { NlpModule } from '@app/core/nlp/nlp.module'; @NgModule({ imports: [ @@ -10,8 +14,12 @@ import { AlgoliaModule } from '@app/core/algolia/algolia.module'; applicationId: environment.algolia.applicationId, searchApiKey: environment.algolia.searchApiKey, indexName: environment.algolia.indexName + }), + NlpModule.forRoot({ + accessToken: environment.dialogflow.accessToken }) ], + providers: [SpeechToTextService, TextToSpeechService, EastereggService], exports: [MaterialModule, AlgoliaModule] }) export class CoreModule {} diff --git a/src/app/core/easteregg.service.ts b/src/app/core/easteregg.service.ts new file mode 100644 index 0000000..bafa86b --- /dev/null +++ b/src/app/core/easteregg.service.ts @@ -0,0 +1,65 @@ +import { debounceTime, distinctUntilChanged, switchMap, filter } from 'rxjs/operators'; +import { NlpService } from './nlp/nlp.service'; +import { Injectable, NgZone } from '@angular/core'; +import { SpeechToTextService } from '@app/core/nlp/speech-to-text.service'; +import { TextToSpeechService } from '@app/core/nlp/text-to-speech.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { from } from 'rxjs/internal/observable/from'; + +@Injectable({ + providedIn: 'root' +}) +export class EastereggService { + constructor( + private zone: NgZone, + private snackBar: MatSnackBar, + private stt: SpeechToTextService, + private tts: TextToSpeechService, + private nlp: NlpService + ) {} + + surprise() { + console.log('Activating speach recognition...'); + this.stt.listen(); + this.setup(); + } + + private setup() { + let sb = null; + this.stt.onstart$.subscribe(() => { + this.zone.run(() => { + sb = this.snackBar.open('Listening...', 'STOP'); + sb.onAction().subscribe(() => { + this.stt.stop(); + sb.dismiss(); + }); + }); + }); + this.stt.onresult$ + .pipe( + // prettier-ignore + debounceTime(200), + distinctUntilChanged(), + filter(transcription => !!transcription), + switchMap(transcription => from(this.nlp.process(transcription))) + ) + .subscribe(response => { + this.stt.stop(); + this.tts.say(response.speech); + }); + this.stt.onend$.subscribe(() => { + this.zone.run(() => { + sb.dismiss(); + }); + }); + this.stt.onerror$.subscribe(error => { + console.error('STT error', error); + this.zone.run(() => { + sb.dismiss(); + }); + }); + this.tts.onend$.subscribe(() => { + this.stt.start(); + }); + } +} diff --git a/src/app/core/nlp/inject-tokens.ts b/src/app/core/nlp/inject-tokens.ts new file mode 100644 index 0000000..3f0b4c1 --- /dev/null +++ b/src/app/core/nlp/inject-tokens.ts @@ -0,0 +1,2 @@ +import { InjectionToken } from '@angular/core'; +export const DIALOGFLOW_TOKEN = new InjectionToken('DIALOGFLOW_TOKEN'); diff --git a/src/app/core/nlp/nlp.module.ts b/src/app/core/nlp/nlp.module.ts new file mode 100644 index 0000000..95d8ade --- /dev/null +++ b/src/app/core/nlp/nlp.module.ts @@ -0,0 +1,25 @@ +import { NgModule, ModuleWithProviders } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { DIALOGFLOW_TOKEN } from '@app/core/nlp/inject-tokens'; +import { NlpService } from './nlp.service'; + +export interface DialogflowTokenInterface { + accessToken: string; +} + +@NgModule({ + providers: [NlpService] +}) +export class NlpModule { + static forRoot(config: DialogflowTokenInterface): ModuleWithProviders { + return { + ngModule: NlpModule, + providers: [ + { + provide: DIALOGFLOW_TOKEN, + useValue: config.accessToken + } + ] + }; + } +} diff --git a/src/app/core/nlp/nlp.service.ts b/src/app/core/nlp/nlp.service.ts new file mode 100644 index 0000000..4e2411b --- /dev/null +++ b/src/app/core/nlp/nlp.service.ts @@ -0,0 +1,27 @@ +import { ApiAiClient, IServerResponse } from 'api-ai-javascript/index.js'; +import { Injectable, Inject, InjectionToken } from '@angular/core'; +import { DIALOGFLOW_TOKEN } from '@app/core/nlp/inject-tokens'; + +@Injectable({ + providedIn: 'root' +}) +export class NlpService { + client: ApiAiClient; + + constructor(@Inject(DIALOGFLOW_TOKEN) private accessToken) { + this.client = new ApiAiClient({ accessToken }); + } + + async process(message: string) { + try { + const response = (await this.client.textRequest(message)) as any; + const url = response.result.contexts && response.result.contexts[0] && response.result.contexts[0].parameters.url; + return { + speech: response.result.fulfillment.speech, + url + }; + } catch (error) { + console.error(error); + } + } +} diff --git a/src/app/core/nlp/speech-to-text.service.ts b/src/app/core/nlp/speech-to-text.service.ts new file mode 100644 index 0000000..b4c45c3 --- /dev/null +++ b/src/app/core/nlp/speech-to-text.service.ts @@ -0,0 +1,124 @@ +import { Injectable, NgZone } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Observable } from 'rxjs/internal/Observable'; +import { NlpService } from '@app/core/nlp/nlp.service'; + +@Injectable({ + providedIn: 'root' +}) +export class SpeechToTextService { + recognizing = false; + recognition; + start_timestamp = 0; + onresult$: Observable; + onend$: Observable<{}>; + onerror$: Observable<{}>; + onstart$: Observable<{}>; + constructor() {} + + listen() { + if (!('webkitSpeechRecognition' in window)) { + console.error('Your device is not compatible with this easter egg. sorry!'); + } else { + this.recognition = new window['webkitSpeechRecognition'](); + this.recognition.continuous = true; + this.recognition.interimResults = true; + + this.setupListeners(); + } + + if (this.recognizing) { + this.recognition.stop(); + return; + } + this.recognition.lang = 'en-US'; + this.recognition.start(); + this.start_timestamp = Date.now(); + } + + private setupListeners() { + this.onstart$ = new Observable(observer => { + this.recognition.onstart = () => { + this.handleOnStart(); + observer.next(event); + }; + return () => (this.recognition.onstart = null); + }); + + this.onerror$ = new Observable(observer => { + this.recognition.onerror = event => { + this.handleOnError(event); + observer.next(event.error); + }; + return () => (this.recognition.onerror = null); + }); + + this.onend$ = new Observable(observer => { + this.recognition.onend = event => { + this.handleOnEnd(event); + observer.next(event); + }; + return () => (this.recognition.onend = null); + }); + + this.onresult$ = new Observable(observer => { + this.recognition.onresult = event => { + const transcript = this.handleOnResult(event); + observer.next(transcript); + }; + return () => (this.recognition.onresult = null); + }); + } + + stop() { + this.recognition.stop(); + } + start() { + this.recognition.start(); + } + + private handleOnStart() { + this.recognizing = true; + } + private handleOnResult(event) { + let interim_transcript = ''; + let final_transcript = ''; + if (typeof event.results === 'undefined') { + this.recognition.onend = null; + this.recognition.stop(); + console.error(`Sorry, your device is not compatible with Speech-To-Text technology.`); + + return; + } + for (let i = event.resultIndex; i < event.results.length; ++i) { + if (event.results[i].isFinal) { + final_transcript += event.results[i][0].transcript; + } else { + interim_transcript += event.results[i][0].transcript; + } + } + + return final_transcript; + } + + private handleOnEnd(event) { + this.recognizing = false; + } + + private handleOnError(event) { + if (event.error === 'no-speech') { + console.log('info_no_speech'); + console.error(`Sorry, I didn't hear anything.`); + } + if (event.error === 'audio-capture') { + console.error(`Sorry, your mic isn't available.`); + } + if (event.error === 'not-allowed') { + if (event.timeStamp - this.start_timestamp < 100) { + console.error(`Sorry, I was blocked from access your mic.`); + } else { + console.error(`Sorry, I couln't access your mic.`); + } + } + } +} diff --git a/src/app/core/nlp/text-to-speech.service.ts b/src/app/core/nlp/text-to-speech.service.ts new file mode 100644 index 0000000..d41a6f8 --- /dev/null +++ b/src/app/core/nlp/text-to-speech.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { Observer } from 'rxjs/internal/types'; + +@Injectable({ + providedIn: 'root' +}) +export class TextToSpeechService { + onend$: Observable<{}>; + _observer: Observer<{}>; + constructor() { + this.onend$ = new Observable(observer => { + this._observer = observer; + return () => {}; + }); + } + + say(text) { + const msg = new SpeechSynthesisUtterance(); + // const voices = window.speechSynthesis.getVoices(); + // console.log(voices); + + // msg.voice = voices[49]; // Note: some voices don't support altering params + // msg['voiceURI'] = 'native'; + // msg.volume = 1; // 0 to 1 + // msg.rate = 1; // 0.1 to 10 + // msg.pitch = 0; // 0 to 2 + msg.text = text; + msg.lang = 'en-US'; + + msg.onend = event => { + this._observer.next(event); + }; + speechSynthesis.speak(msg); + } +} diff --git a/src/app/search-ui/search/search.component.html b/src/app/search-ui/search/search.component.html index e39891f..4d76b6d 100644 --- a/src/app/search-ui/search/search.component.html +++ b/src/app/search-ui/search/search.component.html @@ -9,6 +9,9 @@
+ + {{ applicationsCount }} + Most Relevant diff --git a/src/app/search-ui/search/search.component.ts b/src/app/search-ui/search/search.component.ts index 9e1ea27..fdf1ef2 100644 --- a/src/app/search-ui/search/search.component.ts +++ b/src/app/search-ui/search/search.component.ts @@ -13,6 +13,7 @@ import { SearchService, Application } from './../search.service'; }) export class SearchComponent implements OnInit { hitsPerPage = 20; + applicationsCount = 0; sortOption = 'rating_desc'; private _applications: Application[]; set applications(value: Application[]) { @@ -44,6 +45,8 @@ export class SearchComponent implements OnInit { ngOnInit() { const handleResultSubscription = content => { + this.applicationsCount = content.nbHits; + const categoriesFacet = content.getFacetValues('category') as any[]; if (categoriesFacet.length > 1) { this.searchCategories = categoriesFacet; diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 75e45a6..1569d57 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -7,5 +7,8 @@ export const environment = { applicationId: 'UKIVLRH85G', searchApiKey: '208b30b19b8def35abe4eba171bcb5a2', indexName: 'applications' + }, + dialogflow: { + accessToken: 'e7bb984fbcbf46599df262d7f4e1a0ce' } }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 38e0d07..cf9d613 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -12,6 +12,9 @@ export const environment = { applicationId: 'UKIVLRH85G', searchApiKey: '208b30b19b8def35abe4eba171bcb5a2', indexName: 'applications' + }, + dialogflow: { + accessToken: 'e7bb984fbcbf46599df262d7f4e1a0ce' } }; diff --git a/yarn.lock b/yarn.lock index d382f22..f7a452e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -602,6 +602,10 @@ apache-md5@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/apache-md5/-/apache-md5-1.0.6.tgz#470239d40c54e7c32dd9d6eb11bc3578ecc903c2" +api-ai-javascript@^2.0.0-beta.21: + version "2.0.0-beta.21" + resolved "https://registry.yarnpkg.com/api-ai-javascript/-/api-ai-javascript-2.0.0-beta.21.tgz#43465f6835dc4f0e14e0311c0bb64017f3148b5b" + app-root-path@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/app-root-path/-/app-root-path-2.0.1.tgz#cd62dcf8e4fd5a417efc664d2e5b10653c651b46"