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