generated from nhcarrigan/template
feat: initial prototype
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.angular
|
||||
out/
|
||||
*.tgz
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"cli": {
|
||||
"analytics": false
|
||||
},
|
||||
"projects": {
|
||||
"eclaire": {
|
||||
"projectType": "application",
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/eclaire",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": ["zone.js"],
|
||||
"tsConfig": "tsconfig.json",
|
||||
"assets": ["src/assets"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kb",
|
||||
"maximumError": "1mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "2kb",
|
||||
"maximumError": "4kb"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"port": 4200
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "eclaire:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "eclaire:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "eclaire",
|
||||
"description": "Deepgram-powered speech to speech translation.",
|
||||
"author": "Naomi Carrigan <contact@nhcarrigan.com>",
|
||||
"version": "1.1.0",
|
||||
"main": "app.js",
|
||||
"license": "ISC",
|
||||
"scripts": {
|
||||
"dev": "ng serve",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"build": "ng build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.1.4",
|
||||
"@angular/common": "^19.1.4",
|
||||
"@angular/compiler": "^19.1.4",
|
||||
"@angular/core": "^19.1.4",
|
||||
"@angular/forms": "^19.1.4",
|
||||
"@angular/platform-browser": "^19.1.4",
|
||||
"@angular/platform-browser-dynamic": "^19.1.4",
|
||||
"@angular/router": "^19.1.4",
|
||||
"rxjs": "~7.8.1",
|
||||
"tslib": "^2.8.1",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.1.5",
|
||||
"@angular/cli": "^19.1.5",
|
||||
"@angular/compiler-cli": "^19.1.4",
|
||||
"@nhcarrigan/eslint-config": "5.1.0",
|
||||
"@nhcarrigan/typescript-config": "4.0.0",
|
||||
"@types/jasmine": "~5.1.5",
|
||||
"@types/node": "22.13.1",
|
||||
"eslint": "9.19.0",
|
||||
"jasmine-core": "~5.5.0",
|
||||
"karma": "~6.4.4",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.1",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.7.3"
|
||||
}
|
||||
}
|
||||
Generated
+11622
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
.assistant {
|
||||
text-align: left;
|
||||
color: greenyellow;
|
||||
}
|
||||
|
||||
.user {
|
||||
text-align: right;
|
||||
color: cyan;
|
||||
}
|
||||
|
||||
a {
|
||||
color: white;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<h1>Eclaire</h1>
|
||||
<img src="assets/eclaire.png" alt="Eclaire" height="400" />
|
||||
<p>
|
||||
Usage: {{ this.cost.toLocaleString("en-US", { style: "currency", currency: "USD"})}}
|
||||
</p>
|
||||
<div *ngIf="!connected">
|
||||
<p>When you are ready to connect to the websocket, click the button below.</p>
|
||||
<p>
|
||||
Note that you are billed for time connected, not for time you spend
|
||||
transmitting audio.
|
||||
</p>
|
||||
<p><a routerLink="/config">Reconfigure</a></p>
|
||||
<button (click)="connect()">Connect</button>
|
||||
</div>
|
||||
<div *ngIf="connected">
|
||||
<p>Sometimes our endpointing detection fails to trigger. If you are done speaking, click the button below to force a transcription.</p>
|
||||
<button (click)="finalise()">Force Transcription</button>
|
||||
<p>
|
||||
When you are done with your conversation, we recommend disconnecting to save
|
||||
costs.
|
||||
</p>
|
||||
<button (click)="disconnect()">Disconnect</button>
|
||||
</div>
|
||||
<p>
|
||||
</p>
|
||||
<h2>Transcript</h2>
|
||||
<button (click)="clearTranscript()">Clear Transcript</button>
|
||||
<div style="background-color: black;">
|
||||
<p *ngFor="let message of messages" [class]="message.type">{{ message.content }}</p>
|
||||
</div>
|
||||
<h2>Logs</h2>
|
||||
<button (click)="clearLogs()">Clear Logs</button>
|
||||
<p *ngFor="let log of logs">{{ log }}</p>
|
||||
<h2>Errors</h2>
|
||||
<button (click)="clearErrors()">Clear Errors</button>
|
||||
<p *ngFor="let error of errors">{{ error }}</p>
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
|
||||
import { ConfigService } from "../config.service";
|
||||
import { Config } from "../interfaces/Config";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Router, RouterModule } from "@angular/router";
|
||||
import { SocketService } from "../socket.service.js";
|
||||
|
||||
@Component({
|
||||
selector: "app-agent",
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
templateUrl: "./agent.component.html",
|
||||
styleUrl: "./agent.component.css",
|
||||
})
|
||||
export class AgentComponent {
|
||||
private config: Config | null = null;
|
||||
private transcript: string = "";
|
||||
public messages: { type: "user" | "assistant"; content: string }[] = [];
|
||||
public connected = false;
|
||||
public errors: string[] = [];
|
||||
public logs: string[] = [];
|
||||
public cost = 0;
|
||||
public openedAt = new Date();
|
||||
private keepAlive: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly socket: SocketService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly router: Router
|
||||
) {
|
||||
this.ensureConfig();
|
||||
|
||||
this.socket.on("close", () => {
|
||||
this.connected = false;
|
||||
this.keepAlive && clearInterval(this.keepAlive);
|
||||
this.keepAlive = null;
|
||||
this.logs.push("Connection closed");
|
||||
});
|
||||
|
||||
this.socket.on("error", (error) => {
|
||||
this.errors.push(JSON.stringify(error));
|
||||
this.connected = false;
|
||||
this.socket.disconnect();
|
||||
});
|
||||
|
||||
this.socket.on("Error", (error) => {
|
||||
this.errors.push(JSON.stringify(error));
|
||||
this.connected = false;
|
||||
this.socket.disconnect();
|
||||
});
|
||||
|
||||
this.socket.on("open", () => {
|
||||
this.connected = true;
|
||||
this.logs.push("Connection opened");
|
||||
this.keepAlive = setInterval(() => {
|
||||
this.socket.keepAlive();
|
||||
});
|
||||
window.navigator.mediaDevices
|
||||
.getUserMedia({
|
||||
audio: {
|
||||
sampleRate: 48000,
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
})
|
||||
.then((stream) => {
|
||||
const audioContext = new AudioContext();
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||
|
||||
processor.onaudioprocess = (event) => {
|
||||
const inputData = event.inputBuffer.getChannelData(0);
|
||||
const int16Data = new Int16Array(inputData.length);
|
||||
for (let i = 0; i < inputData.length; i++) {
|
||||
int16Data[i] = Math.max(
|
||||
-32768,
|
||||
Math.min(32767, inputData[i]! * 32768)
|
||||
);
|
||||
}
|
||||
this.socket.sendAudio(int16Data.buffer);
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
this.socket.on("close", () => {
|
||||
processor.disconnect();
|
||||
source.disconnect();
|
||||
audioContext.close();
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error accessing microphone: ", error);
|
||||
this.errors.push("Error accessing microphone: " + error);
|
||||
this.socket.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
this.socket.on("Results", (message) => {
|
||||
const data = JSON.parse(message as string);
|
||||
const transcript = data.channel.alternatives[0].transcript;
|
||||
// Finalizing might kick back an empty transcript, but we don't want to skip that because we need to trigger the translation.
|
||||
if (!transcript && !data.from_finalize) {
|
||||
return;
|
||||
}
|
||||
this.transcript += " ";
|
||||
this.transcript += transcript;
|
||||
const isFinal = data.speech_final || data.from_finalize;
|
||||
if (!isFinal) {
|
||||
console.log("Skipping non-final transcript");
|
||||
return;
|
||||
}
|
||||
this.messages.push({ type: "user", content: this.transcript });
|
||||
const formData = new FormData();
|
||||
formData.append("q", this.transcript);
|
||||
formData.append("source", this.config?.sourceLanguage ?? "en");
|
||||
formData.append("target", this.config?.targetLanguage ?? "en");
|
||||
formData.append("api_key", this.config?.translateKey ?? "");
|
||||
fetch("https://trans.nhcarrigan.com/translate", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
this.messages.push({ type: "assistant", content: data.translatedText });
|
||||
});
|
||||
this.transcript = "";
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureConfig() {
|
||||
this.config = this.configService.getConfig();
|
||||
if (!this.config?.apiKey) {
|
||||
await this.router.navigate(["/config"]);
|
||||
}
|
||||
}
|
||||
|
||||
public disconnect() {
|
||||
this.logs.push("Disconnecting...");
|
||||
this.socket.disconnect();
|
||||
const msElapsed = new Date().getTime() - this.openedAt.getTime();
|
||||
const dollarsPerHour = 0.32;
|
||||
this.cost += (msElapsed / 1000 / 60 / 60) * dollarsPerHour;
|
||||
this.logs.push(
|
||||
`Connected for ${msElapsed}ms. ESTIMATED Cost: $${
|
||||
(msElapsed / 1000 / 60 / 60) * dollarsPerHour
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
public finalise() {
|
||||
this.logs.push("Finalising...");
|
||||
this.socket.finalise();
|
||||
this.logs.push("Finalised!");
|
||||
}
|
||||
|
||||
public async connect() {
|
||||
this.logs.push("Connecting...");
|
||||
this.socket.connect(this.config?.apiKey ?? "");
|
||||
while (!this.socket.isConnected()) {
|
||||
this.logs.push("Waiting for connection...");
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
this.connected = true;
|
||||
this.logs.push("Connected!");
|
||||
}
|
||||
|
||||
public clearErrors() {
|
||||
this.errors = [];
|
||||
}
|
||||
|
||||
public clearLogs() {
|
||||
this.logs = [];
|
||||
}
|
||||
|
||||
public clearTranscript() {
|
||||
this.messages = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<main>
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterOutlet],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.css'
|
||||
})
|
||||
export class AppComponent {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApplicationConfig } from "@angular/core";
|
||||
import { provideRouter } from "@angular/router";
|
||||
|
||||
import { routes } from "./app.routes";
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [provideRouter(routes)],
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Routes } from "@angular/router";
|
||||
import { HomeComponent } from "./home/home.component";
|
||||
import { ConfigComponent } from "./config/config.component";
|
||||
import { AgentComponent } from "./agent/agent.component";
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: "", pathMatch: "full", component: HomeComponent },
|
||||
{ path: "config", component: ConfigComponent },
|
||||
{ path: "agent", component: AgentComponent }
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Config } from "./interfaces/Config";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ConfigService {
|
||||
private config: Config | null = null;
|
||||
public getConfig(): Config | null {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
public setConfig(config: Config) {
|
||||
this.config = config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
form {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 400px;
|
||||
margin: auto;
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
input, select {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.green {
|
||||
background: green;
|
||||
font-family: "Vampyr", monospace;
|
||||
}
|
||||
|
||||
.red {
|
||||
background: red;
|
||||
font-family: "Vampyr", monospace;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<h1>Configuration</h1>
|
||||
<img src="assets/eclaire.png" alt="Eclaire" height="400" />
|
||||
<p>These settings determine how your translator behaves.</p>
|
||||
<p>For your security, all values are stored directly in memory, and are not persisted. Refresh the page to reset to default values.</p>
|
||||
<p>Need a Deepgram API key? Visit https://deepgram.com to get one for free!</p>
|
||||
<p>Translation is powered by our own translation API. To get a key, ask us in Discord. https://chat.nhcarrigan.com</p>
|
||||
<p>A public, VERY low rate limit translation API key is pre-loaded for you. You may use it to test this application, but we do not recommend using it for production.</p>
|
||||
<form>
|
||||
<label for="apiKey">Deepgram API Key:</label>
|
||||
<input
|
||||
name="apiKey"
|
||||
id="apiKey"
|
||||
type="{{ passType }}"
|
||||
[formControl]="apiKey"
|
||||
/>
|
||||
<label for="showPass">Show?</label>
|
||||
<input type="checkbox" id="showPass" name="showPass" (click)="togglePass()" />
|
||||
<label for="translateKey">Translate Key:</label>
|
||||
<input
|
||||
name="translateKey"
|
||||
id="translateKey"
|
||||
type="{{ translatePassType }}"
|
||||
[formControl]="translateKey"
|
||||
/>
|
||||
<label for="showTranslatePass">Show?</label>
|
||||
<input type="checkbox" id="showTranslatePass" name="showTranslatePass" (click)="toggleTranslatePass()" />
|
||||
<label for="sourceLanguage">Source Language:</label>
|
||||
<select name="sourceLanguage" id="sourceLanguage" [formControl]="sourceLanguage">
|
||||
<option value="English">English</option>
|
||||
<option value="German">German</option>
|
||||
<option value="Dutch">Dutch</option>
|
||||
<option value="Swedish">Swedish</option>
|
||||
<option value="Danish">Danish</option>
|
||||
<option value="Spanish">Spanish</option>
|
||||
<option value="French">French</option>
|
||||
<option value="Portuguese">Portuguese</option>
|
||||
<option value="Italian">Italian</option>
|
||||
<option value="Turkish">Turkish</option>
|
||||
<option value="Norwegian">Norwegian</option>
|
||||
<option value="Indonesian">Indonesian</option>
|
||||
</select>
|
||||
<label for="targetLanguage">Target Language:</label>
|
||||
<select name="targetLanguage" id="targetLanguage" [formControl]="targetLanguage">
|
||||
<option value="English">English</option>
|
||||
<option value="Albanian">Albanian</option>
|
||||
<option value="Arabic">Arabic</option>
|
||||
<option value="Azerbaijani">Azerbaijani</option>
|
||||
<option value="Basque">Basque</option>
|
||||
<option value="Bengali">Bengali</option>
|
||||
<option value="Bulgarian">Bulgarian</option>
|
||||
<option value="Catalan">Catalan</option>
|
||||
<option value="Chinese">Chinese</option>
|
||||
<option value="Chinese (traditional)">Chinese (traditional)</option>
|
||||
<option value="Czech">Czech</option>
|
||||
<option value="Danish">Danish</option>
|
||||
<option value="Dutch">Dutch</option>
|
||||
<option value="Esperanto">Esperanto</option>
|
||||
<option value="Estonian">Estonian</option>
|
||||
<option value="Finnish">Finnish</option>
|
||||
<option value="French">French</option>
|
||||
<option value="Galician">Galician</option>
|
||||
<option value="German">German</option>
|
||||
<option value="Greek">Greek</option>
|
||||
<option value="Hebrew">Hebrew</option>
|
||||
<option value="Hindi">Hindi</option>
|
||||
<option value="Hungarian">Hungarian</option>
|
||||
<option value="Indonesian">Indonesian</option>
|
||||
<option value="Irish">Irish</option>
|
||||
<option value="Italian">Italian</option>
|
||||
<option value="Japanese">Japanese</option>
|
||||
<option value="Korean">Korean</option>
|
||||
<option value="Kyrgyz">Kyrgyz</option>
|
||||
<option value="Latvian">Latvian</option>
|
||||
<option value="Lithuanian">Lithuanian</option>
|
||||
<option value="Malay">Malay</option>
|
||||
<option value="Norwegian">Norwegian</option>
|
||||
<option value="Persian">Persian</option>
|
||||
<option value="Polish">Polish</option>
|
||||
<option value="Portuguese">Portuguese</option>
|
||||
<option value="Portuguese (Brazil)">Portuguese (Brazil)</option>
|
||||
<option value="Romanian">Romanian</option>
|
||||
<option value="Russian">Russian</option>
|
||||
<option value="Slovak">Slovak</option>
|
||||
<option value="Slovenian">Slovenian</option>
|
||||
<option value="Spanish">Spanish</option>
|
||||
<option value="Swedish">Swedish</option>
|
||||
<option value="Tagalog">Tagalog</option>
|
||||
<option value="Thai">Thai</option>
|
||||
<option value="Turkish">Turkish</option>
|
||||
<option value="Ukrainian">Ukrainian</option>
|
||||
<option value="Urdu">Urdu</option>
|
||||
</select>
|
||||
</form>
|
||||
<button class="green" (click)="save()">Save Configuration</button>
|
||||
<button class="red" (click)="cancel()">Cancel</button>
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Component } from "@angular/core";
|
||||
import {
|
||||
AbstractControl,
|
||||
FormControl,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from "@angular/forms";
|
||||
import { ConfigService } from "../config.service";
|
||||
import { Config } from "../interfaces/Config";
|
||||
import { Router } from "@angular/router";
|
||||
|
||||
const langCodes = {
|
||||
"English": "en",
|
||||
"Albanian": "sq",
|
||||
"Arabic": "ar",
|
||||
"Azerbaijani": "az",
|
||||
"Basque": "eu",
|
||||
"Bengali": "bn",
|
||||
"Bulgarian": "bg",
|
||||
"Catalan": "ca",
|
||||
"Chinese": "zh",
|
||||
"Chinese (traditional)": "zt",
|
||||
"Czech": "cs",
|
||||
"Danish": "da",
|
||||
"Dutch": "nl",
|
||||
"Esperanto": "eo",
|
||||
"Estonian": "et",
|
||||
"Finnish": "fi",
|
||||
"French": "fr",
|
||||
"Galician": "gl",
|
||||
"German": "de",
|
||||
"Greek": "el",
|
||||
"Hebrew": "he",
|
||||
"Hindi": "hi",
|
||||
"Hungarian": "hu",
|
||||
"Indonesian": "id",
|
||||
"Irish": "ga",
|
||||
"Italian": "it",
|
||||
"Japanese": "ja",
|
||||
"Korean": "ko",
|
||||
"Kyrgyz": "ky",
|
||||
"Latvian": "lv",
|
||||
"Lithuanian": "lt",
|
||||
"Malay": "ms",
|
||||
"Norwegian": "nb",
|
||||
"Persian": "fa",
|
||||
"Polish": "pl",
|
||||
"Portuguese": "pt",
|
||||
"Portuguese (Brazil)": "pb",
|
||||
"Romanian": "ro",
|
||||
"Russian": "ru",
|
||||
"Slovak": "sk",
|
||||
"Slovenian": "sl",
|
||||
"Spanish": "es",
|
||||
"Swedish": "sv",
|
||||
"Tagalog": "tl",
|
||||
"Thai": "th",
|
||||
"Turkish": "tr",
|
||||
"Ukrainian": "uk",
|
||||
"Urdu": "ur",
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-config",
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule],
|
||||
templateUrl: "./config.component.html",
|
||||
styleUrl: "./config.component.css",
|
||||
})
|
||||
export class ConfigComponent {
|
||||
public apiKey = new FormControl("", [Validators.required]);
|
||||
// I am aware that we are committing an API key. This is deliberate - this key has a 10 request per minute rate limit, and is provided for demonstration purposes.
|
||||
public translateKey = new FormControl("accd53eb-371e-4657-81c8-1d2d521407a7", [Validators.required]);
|
||||
public sourceLanguage = new FormControl("English", [
|
||||
Validators.required,
|
||||
() => (control: AbstractControl) =>
|
||||
[
|
||||
"English",
|
||||
"German",
|
||||
"Dutch",
|
||||
"Swedish",
|
||||
"Danish",
|
||||
"Spanish",
|
||||
"French",
|
||||
"Portuguese",
|
||||
"Italian",
|
||||
"Turkish",
|
||||
"Norwegian",
|
||||
"Indonesian",
|
||||
].includes(control.value),
|
||||
]);
|
||||
public targetLanguage = new FormControl("English", [
|
||||
Validators.required,
|
||||
() => (control: AbstractControl) =>
|
||||
[
|
||||
...Object.keys(langCodes),
|
||||
].includes(control.value),
|
||||
]);
|
||||
|
||||
public passType: "password" | "text" = "password";
|
||||
public translatePassType: "password" | "text" = "password";
|
||||
|
||||
public togglePass() {
|
||||
this.passType = this.passType === "password" ? "text" : "password";
|
||||
}
|
||||
|
||||
public toggleTranslatePass() {
|
||||
this.translatePassType = this.translatePassType === "password" ? "text" : "password";
|
||||
}
|
||||
|
||||
public save() {
|
||||
const config: Config = {
|
||||
apiKey: this.apiKey.value ?? "",
|
||||
translateKey: this.translateKey.value ?? "",
|
||||
sourceLanguage: String(langCodes[this.sourceLanguage.value as keyof typeof langCodes]),
|
||||
targetLanguage: String(langCodes[this.targetLanguage.value as keyof typeof langCodes]),
|
||||
};
|
||||
this.configService.setConfig(config);
|
||||
|
||||
this.router.navigate(["/"]);
|
||||
}
|
||||
|
||||
public cancel() {
|
||||
this.router.navigate(["/"]);
|
||||
}
|
||||
|
||||
constructor(private configService: ConfigService, private router: Router) {
|
||||
const config = this.configService.getConfig();
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
this.apiKey.setValue(config.apiKey);
|
||||
this.translateKey.setValue(config.translateKey);
|
||||
this.sourceLanguage.setValue(config.sourceLanguage);
|
||||
this.targetLanguage.setValue(config.targetLanguage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.btn {
|
||||
border: 2px solid black;
|
||||
width: 200px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.green {
|
||||
background: green;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.yellow {
|
||||
background: yellow;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
width: 500px;
|
||||
grid-template-columns: 250px 250px;
|
||||
margin: auto;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<h1>Eclaire</h1>
|
||||
<img src="assets/eclaire.png" alt="Eclaire" height="400" />
|
||||
<p>Welcome to your new favourite voice-powered translator.</p>
|
||||
<div class="grid">
|
||||
<a routerLink="/agent" class="green btn">Start Translation</a>
|
||||
<a routerLink="/config" class="yellow btn">Configuration</a>
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
templateUrl: './home.component.html',
|
||||
styleUrl: './home.component.css'
|
||||
})
|
||||
export class HomeComponent {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface Config {
|
||||
apiKey: string;
|
||||
translateKey: string;
|
||||
sourceLanguage: string;
|
||||
targetLanguage: string;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
type Event = "Results" | "Metadata" | "UtteranceEnd" | "SpeechStarted" | "error" | "close" | "open" | "Error";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class SocketService {
|
||||
private socket: WebSocket | null = null;
|
||||
|
||||
private listeners: Record<Event | string, Array<(data: unknown)=> void>> = {};
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
constructor() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param event
|
||||
* @param callback
|
||||
*/
|
||||
public on(event: Event, callback: (data: unknown)=> void): void {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = [];
|
||||
}
|
||||
this.listeners[event].push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public disconnect(): void {
|
||||
if (!this.socket) {
|
||||
throw new Error("Socket is not connected");
|
||||
}
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param key
|
||||
*/
|
||||
public connect(key: string): void {
|
||||
if (this.isConnected()) {
|
||||
throw new Error("Socket is already connected");
|
||||
}
|
||||
if (!key) {
|
||||
throw new Error("API key is required");
|
||||
}
|
||||
this.socket = new WebSocket("wss://api.deepgram.com/v1/listen?model=nova-3-general&encoding=linear16&sample_rate=48000&channels=1&endpointing=500&punctuate=true", [ "token", key ]);
|
||||
this.socket.onopen = () => {
|
||||
console.log("open");
|
||||
if (this.listeners.open) {
|
||||
for (const callback of this.listeners.open) {
|
||||
callback({ type: "open" });
|
||||
}
|
||||
}
|
||||
};
|
||||
this.socket.onmessage = (event) => {
|
||||
console.log(event.data);
|
||||
const { type } = JSON.parse(event.data);
|
||||
if (this.listeners[type]) {
|
||||
for (const callback of this.listeners[type]) {
|
||||
callback(event.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
this.socket.onclose = (a) => {
|
||||
console.log(a);
|
||||
if (this.listeners.close) {
|
||||
for (const callback of this.listeners.close) {
|
||||
callback({ type: "close" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.socket = null;
|
||||
};
|
||||
this.socket.onerror = (error) => {
|
||||
console.error(error);
|
||||
if (this.listeners.error) {
|
||||
for (const callback of this.listeners.error) {
|
||||
callback({ error, type: "error" });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public isConnected(): boolean {
|
||||
return this.socket !== null && this.socket.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param audio
|
||||
*/
|
||||
public sendAudio(audio: ArrayBuffer): void {
|
||||
if (!this.socket) {
|
||||
throw new Error("Socket is not connected");
|
||||
}
|
||||
this.socket.send(audio);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public keepAlive(): void {
|
||||
if (!this.socket) {
|
||||
throw new Error("Socket is not connected");
|
||||
}
|
||||
this.socket.send(JSON.stringify({ type: "KeepAlive" }));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public finalise(): void {
|
||||
if (!this.socket) {
|
||||
throw new Error("Socket is not connected");
|
||||
}
|
||||
this.socket.send(JSON.stringify({ type: "Finalize" }));
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 490 KiB |
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Eclaire</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body style="margin: 0">
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
<script src="https://cdn.nhcarrigan.com/headers/index.js"></script>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { bootstrapApplication } from "@angular/platform-browser";
|
||||
import { AppComponent } from "./app/app.component";
|
||||
import { appConfig } from "./app/app.config";
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig).catch((error: unknown) => {
|
||||
console.error(error);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "@nhcarrigan/typescript-config",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"target": "es5",
|
||||
"module": "ES2022",
|
||||
"lib": ["ES2022", "dom"]
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user