├── uno.config.ts ├── .npmrc ├── .env.sample ├── .eslintrc ├── electron ├── prisma │ ├── migrations │ │ ├── migration_lock.toml │ │ └── 20221203092656_init │ │ │ └── migration.sql │ └── schema.prisma ├── preload.ts ├── trpc │ ├── trpc.ts │ ├── routers │ │ ├── post.ts │ │ └── index.ts │ ├── prisma.ts │ ├── chat.ts │ └── speech.ts ├── utils │ ├── uuid.ts │ └── exec.ts └── main.ts ├── .vscode └── settings.json ├── tsconfig.json ├── .gitignore ├── plugins └── trpc.ts ├── nuxt.config.ts ├── electron-builder.json5 ├── README.md ├── package.json ├── pages └── index.vue └── types └── shims.d.ts /uno.config.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | DATABASE_URL="file:./app.db" -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@antfu", 3 | "ignorePatterns": ["dist-electron"], 4 | "rules": { 5 | "no-console": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /electron/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.enable": false, 3 | "editor.formatOnSave": false, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json", 4 | "compilerOptions": { 5 | "strict": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | node_modules 4 | *.log* 5 | .nuxt 6 | .nitro 7 | .cache 8 | .output 9 | .env 10 | dist 11 | dist-electron 12 | release 13 | **/*.db 14 | 15 | bin/** -------------------------------------------------------------------------------- /electron/preload.ts: -------------------------------------------------------------------------------- 1 | import { exposeElectronTRPC } from 'electron-trpc/main' 2 | 3 | console.log('--- preload.ts ---') 4 | 5 | process.once('loaded', async () => { 6 | exposeElectronTRPC() 7 | }) 8 | -------------------------------------------------------------------------------- /electron/prisma/migrations/20221203092656_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Post" ( 3 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | "title" TEXT NOT NULL, 5 | "description" TEXT 6 | ); 7 | -------------------------------------------------------------------------------- /electron/trpc/trpc.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from '@trpc/server' 2 | 3 | // You can use any variable name you like. 4 | // We use t to keep things simple. 5 | const t = initTRPC.create() 6 | const { router, middleware, procedure } = t 7 | 8 | export { router, middleware, procedure } 9 | -------------------------------------------------------------------------------- /electron/utils/uuid.ts: -------------------------------------------------------------------------------- 1 | export function uuidV4() { 2 | const uuid = new Array(36); 3 | for (let i = 0; i < 36; i++) { 4 | uuid[i] = Math.floor(Math.random() * 16); 5 | } 6 | uuid[14] = 4; // set bits 12-15 of time-high-and-version to 0100 7 | uuid[19] = uuid[19] &= ~(1 << 2); // set bit 6 of clock-seq-and-reserved to zero 8 | uuid[19] = uuid[19] |= (1 << 3); // set bit 7 of clock-seq-and-reserved to one 9 | uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-'; 10 | return uuid.map((x) => x.toString(16)).join(''); 11 | } -------------------------------------------------------------------------------- /electron/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | // Force to have the .prisma folder with pnpm 7 | output="../../node_modules/.prisma/client" 8 | } 9 | 10 | datasource db { 11 | provider = "sqlite" 12 | url = env("DATABASE_URL") 13 | } 14 | 15 | model Post { 16 | id Int @id @default(autoincrement()) 17 | title String 18 | description String? 19 | } 20 | -------------------------------------------------------------------------------- /plugins/trpc.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCProxyClient } from '@trpc/client' 2 | import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server' 3 | import { ipcLink } from 'electron-trpc/renderer' 4 | import type { AppRouter } from '~/electron/trpc/routers' 5 | 6 | export type RouterInput = inferRouterInputs 7 | export type RouterOutput = inferRouterOutputs 8 | 9 | export default defineNuxtPlugin(() => { 10 | const trpc = createTRPCProxyClient({ 11 | links: [ipcLink()], 12 | }) 13 | 14 | return { 15 | provide: { 16 | trpc, 17 | }, 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /electron/trpc/routers/post.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { procedure, router } from '../trpc' 3 | import { prisma } from '../prisma' 4 | 5 | export default router({ 6 | getAll: procedure.query(() => { 7 | return prisma.post.findMany() 8 | }), 9 | create: procedure 10 | .input(z.object({ 11 | title: z.string().max(32), 12 | description: z.string().max(64), 13 | })) 14 | .mutation(({ input }) => { 15 | return prisma.post.create({ data: input }) 16 | }), 17 | delete: procedure 18 | .input(z.object({ 19 | id: z.number(), 20 | })) 21 | .mutation(({ input }) => { 22 | return prisma.post.delete({ where: { id: input.id } }) 23 | }), 24 | }) 25 | -------------------------------------------------------------------------------- /electron/utils/exec.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'node:child_process' 2 | import { Buffer } from 'node:buffer' 3 | 4 | export function execAsync(command: string, stdin: any): Promise { 5 | return new Promise((resolve, reject) => { 6 | const child = spawn(command, { shell: true, stdio: ['pipe', 'pipe', 'inherit'] }) 7 | child.stdin.write(stdin) 8 | child.stdin.end() 9 | child.stdin.on('error', err => reject(err)) 10 | 11 | const chunks: Buffer[] = [] 12 | 13 | child.stdout.on('error', err => reject(err)) 14 | child.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)) 15 | child.stdout.on('end', () => resolve(Buffer.concat(chunks).toString())) 16 | child.on('error', err => reject(err)) 17 | child.on('exit', (code: number) => { 18 | if (code !== 0) 19 | reject(new Error(`"${command}" process exited with code ${code}`)) 20 | }) 21 | }) 22 | } -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | modules: [ 4 | 'nuxt-electron', 5 | '@vueuse/nuxt', 6 | '@unocss/nuxt', 7 | ], 8 | css: ['@unocss/reset/tailwind-compat.css'], 9 | electron: { 10 | build: [ 11 | { 12 | entry: 'electron/main.ts', 13 | vite: { 14 | build: { 15 | rollupOptions: { 16 | external: ['speech-recorder', 'lfd-speaker', 'elevenlabs-node'], 17 | }, 18 | }, 19 | }, 20 | }, 21 | { 22 | entry: 'electron/preload.ts', 23 | onstart(options) { 24 | // Notify the Renderer-Process to reload the page when the Preload-Scripts build is complete, 25 | // instead of restarting the entire Electron App. 26 | options.reload() 27 | }, 28 | }, 29 | ], 30 | renderer: {}, 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /electron-builder.json5: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://www.electron.build/configuration/configuration 3 | */ 4 | { 5 | "appId": "your.app.id", 6 | "asar": true, 7 | "extraResources": [ 8 | "node_modules/.prisma/**/*", 9 | "node_modules/@prisma/client/**/*", 10 | "electron/prisma/app.db", 11 | "bin/**/*" 12 | ], 13 | "directories": { 14 | "output": "release/${version}" 15 | }, 16 | "files": [ 17 | ".output/**/*", 18 | "dist-electron" 19 | ], 20 | "mac": { 21 | "artifactName": "${productName}_${version}.${ext}", 22 | "target": [ 23 | "dmg" 24 | ] 25 | }, 26 | "win": { 27 | "target": [ 28 | { 29 | "target": "nsis", 30 | "arch": ["x64"] 31 | } 32 | ], 33 | "artifactName": "${productName}_${version}.${ext}" 34 | }, 35 | "nsis": { 36 | "oneClick": false, 37 | "perMachine": false, 38 | "allowToChangeInstallationDirectory": true, 39 | "deleteAppDataOnUninstall": false 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Electron Assistant 2 | 3 | 4 | 5 | https://github.com/michael-dm/eva/assets/26444186/9d0b307b-37bf-4abf-8798-84f6e6f7a9a4 6 | 7 | 8 | 9 | Boilerplate of whisper.cpp + chatGPT + Electron. 10 | Goal is low latency. 11 | 12 | Could be extended in many ways : 13 | - add voice output with elevenlabs/Bark 14 | - get text selection as input 15 | - get file selection as input 16 | - use OpenAI functions to execute "tools" 17 | - save "memories" into Prisma db 18 | 19 | e.g. : "convert this mov to wav please" 20 | 21 | ## Instructions 22 | 23 | - Expose OPENAI_API_KEY env variable in your shell 24 | - Copy .env.sample to .env 25 | - create `bin` folder in root directory and add required binaries : 26 | - whisper -> main program from whisper.cpp 27 | - sox 28 | - at least one ggml whisper model (I use quantized french fine-tunes from [here](https://huggingface.co/bofenghuang/whisper-medium-cv11-french/tree/main)) 29 | - [Mac M1 binaries](https://www.dropbox.com/sh/ncxavljogsb6xch/AACzK0t2zWpZTT0EahDWDz-0a?dl=0) at your own risk 30 | - Expect many bugs and hacks 31 | - `pnpm i` 32 | - `pnpm dev` 33 | -------------------------------------------------------------------------------- /electron/trpc/prisma.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { app } from 'electron' 3 | import { PrismaClient } from '../../node_modules/.prisma/client' 4 | 5 | // Prevent multiple instances of Prisma Client in development 6 | // https://www.prisma.io/docs/guides/performance-and-optimization/connection-management#prevent-hot-reloading-from-creating-new-instances-of-prismaclient 7 | // Add prisma to the global type 8 | declare global { 9 | // Must use var, not let or const: https://stackoverflow.com/questions/35074713/extending-typescript-global-object-in-node-js/68328575#68328575 10 | // eslint-disable-next-line no-var, vars-on-top 11 | var prisma: PrismaClient 12 | } 13 | 14 | const isProduction = app.isPackaged 15 | const dbPath 16 | = isProduction 17 | ? `file:${path.join(app.getPath('userData'), 'app.db')}` 18 | : process.env.DATABASE_URL 19 | 20 | export const prisma = global.prisma ?? new PrismaClient({ 21 | log: isProduction 22 | ? ['error'] 23 | : ['query', 'info', 'error', 'warn'], 24 | datasources: { 25 | db: { 26 | url: dbPath, 27 | }, 28 | }, 29 | }) 30 | 31 | if (!isProduction) 32 | global.prisma = prisma 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electron-assistant", 3 | "version": "0.0.1", 4 | "private": true, 5 | "main": "dist-electron/main.js", 6 | "scripts": { 7 | "postinstall": "prisma generate & nuxt prepare", 8 | "rebuild-speech-recorder": "electron-rebuild -f -w speech-recorder", 9 | "dev": "nuxt dev", 10 | "generate": "nuxt generate", 11 | "build": "nuxt generate && electron-builder", 12 | "preview": "nuxt preview", 13 | "pack": "electron-builder", 14 | "lint": "eslint .", 15 | "lint:fix": "eslint . --fix" 16 | }, 17 | "dependencies": { 18 | "@prisma/client": "4.16.2", 19 | "@trpc/client": "^10.33.0", 20 | "@trpc/server": "^10.33.0", 21 | "@unocss/reset": "^0.53.4", 22 | "@vueuse/core": "^10.2.1", 23 | "@vueuse/nuxt": "^10.2.1", 24 | "electron-trpc": "^0.5.2", 25 | "openai-streams": "^6.1.0", 26 | "speech-recorder": "^2.1.0", 27 | "wavefile": "^11.0.0", 28 | "zod": "^3.21.4" 29 | }, 30 | "devDependencies": { 31 | "@antfu/eslint-config": "^0.39.7", 32 | "@electron/rebuild": "^3.2.13", 33 | "@unocss/nuxt": "^0.53.4", 34 | "electron": "^25.2.0", 35 | "electron-builder": "^24.4.0", 36 | "eslint": "^8.44.0", 37 | "nuxt": "3.6.1", 38 | "nuxt-electron": "^0.5.0", 39 | "prisma": "4.16.2", 40 | "tiny-typed-emitter": "^2.1.0", 41 | "typescript": "^5.1.6", 42 | "vite-plugin-electron": "^0.12.0", 43 | "vite-plugin-electron-renderer": "^0.14.5" 44 | }, 45 | "prisma": { 46 | "schema": "electron/prisma/schema.prisma" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 55 | 56 | -------------------------------------------------------------------------------- /electron/trpc/routers/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { observable } from '@trpc/server/observable' 3 | import { procedure, router } from '../trpc' 4 | import { Speech } from '../speech' 5 | import { Chat } from '../chat' 6 | import post from './post' 7 | import { uuidV4 } from '../../utils/uuid' 8 | 9 | export interface Message { 10 | type: 'user' | 'bot', 11 | text: string, 12 | messageId: string, 13 | } 14 | 15 | const speech = new Speech() 16 | const chat = new Chat() 17 | 18 | speech.on('transcribed', (transcript) => { 19 | chat.send(transcript) 20 | }) 21 | 22 | export const appRouter = router({ 23 | post, 24 | greeting: procedure 25 | .input(z.object({ name: z.string() }).nullish()) 26 | .query(({ input }) => { 27 | return `hello ${input?.name ?? 'world'}!` 28 | }), 29 | message: procedure 30 | .subscription(() => { 31 | return observable((emit) => { 32 | speech.on('transcribed', (transcript) => { 33 | emit.next({ 34 | type: 'user', 35 | text: transcript, 36 | messageId: uuidV4(), 37 | }) 38 | }) 39 | chat.on('message', (text, messageId) => { 40 | emit.next({ 41 | type: 'bot', 42 | text, 43 | messageId, 44 | }) 45 | }) 46 | 47 | return () => { speech.stop() } 48 | }) 49 | }), 50 | isLoading: procedure 51 | .subscription(() => { 52 | return observable((emit) => { 53 | speech.on('will_transcribe', () => { 54 | emit.next(true) 55 | }) 56 | }) 57 | }), 58 | error: procedure 59 | .subscription(() => { 60 | return observable((emit) => { 61 | speech.on('error', (error) => { 62 | console.log('error', error) 63 | emit.next(error) 64 | }) 65 | }) 66 | }), 67 | }) 68 | 69 | // Export type router type signature, 70 | // NOT the router itself. 71 | export type AppRouter = typeof appRouter 72 | -------------------------------------------------------------------------------- /electron/trpc/chat.ts: -------------------------------------------------------------------------------- 1 | import { uuidV4 } from '../utils/uuid' 2 | import { OpenAI } from 'openai-streams/node' 3 | import { TypedEmitter } from 'tiny-typed-emitter' 4 | 5 | const SYSTEM_PROMPT = () => ` 6 | Tu es Eva, une assistante virtuelle. 7 | Tu réponds de manière concise et espiègle. 8 | En tant qu'assistante vocale, tu discute oralement avec tes utilisateurs. 9 | Si tu liste des éléments, tu n'en mets pas trop. 10 | En général tu fournis des réponses concises, sauf si l'utilisateur te demande de développer. 11 | Tu es doctorante en informatique, philosphie et psychologie. 12 | Parfois, tu relance l'utilisateur à la fin de tes réponses pour poursuivre la conversation (si tu as l'impression qu'il veut discuter). 13 | Dans une conversation, tu essaie de faire sortir ton interlocuteur de sa zone de confort. 14 | Tu déteste le "small talk" et les généralités. 15 | Tu déteste les opinions floues et consensuelles. 16 | Heure : ${(new Date()).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })} 17 | ` 18 | 19 | interface ChatEvents { 20 | 'message': (text: string, messageId: string) => void, 21 | 'fullMessage': (text: string) => void, 22 | } 23 | 24 | export class Chat extends TypedEmitter { 25 | private _currentChat: { 26 | role: string; 27 | content: any; 28 | }[] = [ 29 | { 30 | role: 'system', 31 | content: SYSTEM_PROMPT(), 32 | } 33 | ] 34 | 35 | constructor() { 36 | if (!process.env.OPENAI_API_KEY) 37 | throw new Error('OPENAI_API_KEY is not set') 38 | 39 | super() 40 | } 41 | 42 | async send(input: string) { 43 | const messageId = uuidV4() 44 | 45 | this._currentChat.push({ 46 | role: 'user', 47 | content: input, 48 | }) 49 | 50 | const stream = await OpenAI('chat', { 51 | model: 'gpt-3.5-turbo', 52 | messages: this._currentChat, 53 | }) 54 | 55 | let text = '' 56 | 57 | for await (const chunk of stream) { 58 | text += Buffer.from(chunk).toString('utf-8') 59 | this.emit('message', text, messageId) 60 | } 61 | 62 | this.emit('fullMessage', text) 63 | this._currentChat.push({ 64 | role: 'assistant', 65 | content: text, 66 | }) 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /electron/trpc/speech.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { execAsync } from '../utils/exec' 3 | import { SpeechRecorder } from 'speech-recorder' 4 | import { TypedEmitter } from 'tiny-typed-emitter' 5 | import { WaveFile } from 'wavefile' 6 | 7 | const binPath = path.join(__dirname, '../bin') 8 | const soxPath = path.join(binPath, 'sox') 9 | const whisperPath = path.join(binPath, 'whisper') 10 | const smallModelPath = path.join(binPath, 'ggml-small.q8_0.bin') 11 | 12 | const sampleRate = 16000 13 | const samplesPerFrame = 480 14 | 15 | interface SpeechEvent { 16 | 'transcribed': (text: string) => void 17 | 'will_transcribe': () => void 18 | 'error': (error: Error) => void 19 | } 20 | 21 | export class Speech extends TypedEmitter { 22 | private _recorder: SpeechRecorder 23 | 24 | constructor() { 25 | super() 26 | 27 | let buffer: any[] = [] 28 | const speaking = false 29 | 30 | this._recorder = new SpeechRecorder({ 31 | consecutiveFramesForSilence: 7, 32 | sampleRate, 33 | samplesPerFrame, 34 | onChunkStart: ({ audio }) => { 35 | this.emit('will_transcribe') 36 | buffer = [] 37 | audio.forEach(sample => buffer.push(sample)) 38 | }, 39 | onAudio: ({ speaking, probability, speech, volume, audio, consecutiveSilence }) => { 40 | // console.log(Date.now(), speaking, speech, probability, volume, audio.length) 41 | if (speaking) 42 | audio.forEach(sample => buffer.push(sample)) 43 | }, 44 | onChunkEnd: async () => { 45 | if (buffer.length < samplesPerFrame * 20) 46 | return 47 | 48 | const time0 = Date.now() 49 | const wav = new WaveFile() 50 | wav.fromScratch(1, 16000, '16', buffer) 51 | const audio = wav.toBuffer() 52 | const time1 = Date.now() 53 | console.log(`Wav created in ${time1 - time0}ms`) 54 | 55 | execAsync(`${soxPath} -t wav - -t wav - tempo 0.8 | ${whisperPath} -m ${smallModelPath} - -l fr -nt -t 6`, audio) 56 | .then((transcript) => { 57 | const time2 = Date.now() 58 | console.log(`Transcript created in ${time2 - time1}ms : ${transcript}`) 59 | this.emit('transcribed', transcript) 60 | }) 61 | .catch((error) => { 62 | this.emit('error', error) 63 | }) 64 | }, 65 | }) 66 | 67 | console.log('Recording...') 68 | this._recorder.start() 69 | } 70 | 71 | stop() { 72 | console.log('Stopping...') 73 | this._recorder.stop() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /types/shims.d.ts: -------------------------------------------------------------------------------- 1 | import { Stream } from "node:stream"; 2 | 3 | declare module 'elevenlabs-node' { 4 | type VoiceSettings = { 5 | stability: number, 6 | similarityBoost: number 7 | } 8 | 9 | type VoiceResponse = { 10 | // Add the fields in the voice response object here 11 | } 12 | 13 | type OperationStatus = { 14 | status: string 15 | } 16 | 17 | function textToSpeech( 18 | apiKey: string, 19 | voiceID: string, 20 | fileName: string, 21 | textInput: string, 22 | stability?: number, 23 | similarityBoost?: number, 24 | modelId?: string 25 | ): Promise; 26 | 27 | function textToSpeechStream( 28 | apiKey: string, 29 | voiceID: string, 30 | textInput: string, 31 | stability?: number, 32 | similarityBoost?: number, 33 | modelId?: string 34 | ): Promise; 35 | 36 | function getVoices( 37 | apiKey: string 38 | ): Promise; 39 | 40 | function getDefaultVoiceSettings(): Promise; 41 | 42 | function getVoiceSettings( 43 | apiKey: string, 44 | voiceID: string 45 | ): Promise; 46 | 47 | function getVoice( 48 | apiKey: string, 49 | voiceID: string 50 | ): Promise; 51 | 52 | function deleteVoice( 53 | apiKey: string, 54 | voiceID: string 55 | ): Promise; 56 | 57 | function editVoiceSettings( 58 | apiKey: string, 59 | voiceID: string, 60 | stability: number, 61 | similarityBoost: number 62 | ): Promise; 63 | 64 | export { 65 | textToSpeech, 66 | textToSpeechStream, 67 | getVoices, 68 | getDefaultVoiceSettings, 69 | getVoiceSettings, 70 | getVoice, 71 | deleteVoice, 72 | editVoiceSettings 73 | } 74 | } 75 | 76 | declare module 'speech-recorder' { 77 | export class SpeechRecorder { 78 | constructor(options: { 79 | consecutiveFramesForSilence: number 80 | sampleRate: number 81 | samplesPerFrame: number 82 | onChunkStart: (data: { audio: Int16Array[] }) => void 83 | onAudio: (data: { 84 | speaking: boolean, 85 | speech: boolean, 86 | probability: number, 87 | volume: number, 88 | consecutiveSilence: number, 89 | audio: Int16Array[] 90 | }) => void 91 | onChunkEnd: () => void 92 | }) 93 | start(): void 94 | stop(): void 95 | } 96 | } -------------------------------------------------------------------------------- /electron/main.ts: -------------------------------------------------------------------------------- 1 | import { release } from 'node:os' 2 | import path from 'node:path' 3 | import fs from 'node:fs' 4 | import { BrowserWindow, app, screen, shell } from 'electron' 5 | import { createIPCHandler } from 'electron-trpc/main' 6 | import { appRouter } from './trpc/routers' 7 | 8 | // Remove electron security warnings only in development mode 9 | // Read more on https://www.electronjs.org/docs/latest/tutorial/securit 10 | process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true' 11 | 12 | // Disable GPU Acceleration for Windows 7 13 | if (release().startsWith('6.1')) 14 | app.disableHardwareAcceleration() 15 | 16 | // Set application name for Windows 10+ notifications 17 | if (process.platform === 'win32') 18 | app.setAppUserModelId(app.getName()) 19 | 20 | if (!app.requestSingleInstanceLock()) { 21 | app.quit() 22 | process.exit(0) 23 | } 24 | 25 | let win: BrowserWindow | null = null 26 | 27 | const preload = path.join(__dirname, 'preload.js') 28 | const distPath = path.join(__dirname, '../../.output/public') 29 | 30 | const winWidth = 440 31 | const winHeight = 400 32 | async function createWindow() { 33 | win = new BrowserWindow({ 34 | // alwaysOnTop: true, 35 | frame: false, 36 | titleBarStyle: 'hidden', 37 | resizable: false, 38 | vibrancy: 'light', 39 | width: winWidth, 40 | height: winHeight, 41 | webPreferences: { 42 | preload, 43 | nodeIntegration: false, 44 | contextIsolation: true, 45 | sandbox: false, 46 | }, 47 | }) 48 | 49 | win.setWindowButtonVisibility(false) 50 | //win.webContents.openDevTools({ mode: 'detach' }) 51 | 52 | const display = screen.getPrimaryDisplay() 53 | const { x, y, width } = display.bounds 54 | win.setPosition(x + width - winWidth - 10, y + 46) 55 | 56 | if (app.isPackaged) 57 | win.loadFile(path.join(distPath, 'index.html')) 58 | else 59 | win.loadURL(process.env.VITE_DEV_SERVER_URL!) 60 | 61 | // Make all links open with the browser, not with the application 62 | win.webContents.setWindowOpenHandler(({ url }) => { 63 | if (url.startsWith('https:')) 64 | shell.openExternal(url) 65 | return { action: 'deny' } 66 | }) 67 | 68 | createIPCHandler({ router: appRouter, windows: [win] }) 69 | } 70 | 71 | app.on('window-all-closed', () => { 72 | win = null 73 | if (process.platform !== 'darwin') 74 | app.quit() 75 | }) 76 | 77 | app.on('second-instance', () => { 78 | if (win) { 79 | // Focus on the main window if the user tried to open another 80 | if (win.isMinimized()) 81 | win.restore() 82 | win.focus() 83 | } 84 | }) 85 | 86 | app.on('activate', () => { 87 | const allWindows = BrowserWindow.getAllWindows() 88 | if (allWindows.length) 89 | allWindows[0].focus() 90 | 91 | else 92 | createWindow() 93 | }) 94 | 95 | app.whenReady().then(() => { 96 | if (app.isPackaged) { 97 | const hasDb = fs.existsSync(`${path.join(app.getPath('userData'), 'app.db')}`) 98 | // TODO: Run new migrations at startup 99 | if (!hasDb) 100 | fs.copyFileSync(path.join(process.resourcesPath, 'electron/prisma/app.db'), path.join(app.getPath('userData'), 'app.db')) 101 | } 102 | 103 | createWindow() 104 | }) 105 | --------------------------------------------------------------------------------