feat: initial prototype

This commit is contained in:
2025-10-31 18:22:52 -07:00
parent d8a3738388
commit a9cd135c7a
25 changed files with 12483 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.angular
out/
*.tgz
+67
View File
@@ -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"
}
}
}
}
}
+43
View File
@@ -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"
}
}
+11622
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
.assistant {
text-align: left;
color: greenyellow;
}
.user {
text-align: right;
color: cyan;
}
a {
color: white;
}
+36
View File
@@ -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>
+181
View File
@@ -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 = [];
}
}
View File
+3
View File
@@ -0,0 +1,3 @@
<main>
<router-outlet></router-outlet>
</main>
+14
View File
@@ -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 {
}
+8
View File
@@ -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)],
};
+10
View File
@@ -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 }
];
+16
View File
@@ -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;
}
}
+20
View File
@@ -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;
}
+95
View File
@@ -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>
+137
View File
@@ -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);
}
}
+22
View File
@@ -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;
}
+7
View File
@@ -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>
+13
View File
@@ -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 {
}
+6
View File
@@ -0,0 +1,6 @@
export interface Config {
apiKey: string;
translateKey: string;
sourceLanguage: string;
targetLanguage: string;
}
+128
View File
@@ -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

+13
View File
@@ -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>
+7
View File
@@ -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);
});
+17
View File
@@ -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
}
}