From ee8132a5639b7e88a7c01d33a21588f5b145daf0 Mon Sep 17 00:00:00 2001 From: Richard Marmorstein Date: Mon, 30 Jun 2025 16:00:35 -0700 Subject: [PATCH] Replace native audio modules with ffmpeg and rename chat command --- bun.lock | 25 --------------- src/chat.ts | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 32 ++++++++++++++++++- 3 files changed, 121 insertions(+), 26 deletions(-) create mode 100644 src/chat.ts diff --git a/bun.lock b/bun.lock index c4bbd32..8b1bd45 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,6 @@ "name": "tts-cli", "dependencies": { "@clack/prompts": "^0.10.0", - "bun": "^1.2.2", "clipanion": "^4.0.0-rc.4", "debug": "^4.4.0", "hume": "^0.10.3", @@ -27,28 +26,6 @@ "@clack/prompts": ["@clack/prompts@0.10.0", "", { "dependencies": { "@clack/core": "0.4.1", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-H3rCl6CwW1NdQt9rE3n373t7o5cthPv7yUoxF2ytZvyvlJv89C5RYMJu83Hed8ODgys5vpBU0GKxIRG83jd8NQ=="], - "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wEUBqSD7KDUx6xcOlNs/lDcbzJYH9CEPVZzAaJ+F3Zp8hORQYJ07byidQ0YgNMc1QhJq4xLaClMDLga6u2abIQ=="], - - "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-kBeKYLNAwQrcJ1HPcuDGo3PGEfDvzHbL5N5kiaui1+eluyCS2yJZvuy2ddg9b0vqQkgzNu7D1keiRLpdR2Wneg=="], - - "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-oYqGnocQ7eY5wfthGivpXd0lzLYklXU4rEKXU4+oLaiFeFnY3lyszv85UHjywuB5bl7xybmSIE8tN8ie36WY1w=="], - - "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-hbi7ykhLAtoBZl7mox5Qg6xVy9FcnPNibo/h6A1oElXBl4pRaVGNFDsuDmb80f9sFf4yZL0vwYyb47hL89MiGw=="], - - "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.2.3", "", { "os": "linux", "cpu": "none" }, "sha512-nSB6FU29OW+8zUP5jRrQEAXl9m6Jd8vp5Swplh3prc0L0niYdtan39wouCxDVFNJafC0DaeKQme4UMQJ65s9qw=="], - - "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-oxubd+zJW8+1H02bwzne8lj2EtvgoRU6w245hWMtwjo15IET/YGc01ndFRd42FX02bwo926N+7Jm7NFzS8GOHQ=="], - - "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-PrkC18s81ImpHMdqgFXvyq9Oxed8mzlYb709DsuuoV9NL3nrzQzfqq8JStz/g3dYMc4obrljG/EKXGfMaJMJVA=="], - - "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-481HyvBKKXrKH2VZsSBVsqsqxcnncqpsKz7qgTqnSEToCSyNqc3bGrI6pN6haI9nLawJpIh+oNXCg4JISUi6bg=="], - - "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-1VsFwYMOryIsnTGPG70N1HAXoa8Fa7s+XAtP2Y010tao0omgpM0S6Go0Uc7VJ8g+yHKyIw79leRMwUs35ysQZA=="], - - "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-z3G7jft1hbiiX47rY666i3HFJZBqCLgddr7e0ccoE/kUk4U5IB08mqJZYW0vaaL8zy0wuEF2h5VkK0S/iryLWw=="], - - "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-pvtDgWRclz/bxLEWUAppj854MklfTu01DfHAvKzV1UAHbBOlurC8+StpE7EhITAHC/jBK9U39eVYnZNvumVU9w=="], - "@types/bun": ["@types/bun@1.2.3", "", { "dependencies": { "bun-types": "1.2.3" } }, "sha512-054h79ipETRfjtsCW9qJK8Ipof67Pw9bodFWmkfkaUaRiIQ1dIV2VTlheshlBx3mpKr0KeK8VqnMMCtgN9rQtw=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], @@ -67,8 +44,6 @@ "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], - "bun": ["bun@1.2.3", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.2.3", "@oven/bun-darwin-x64": "1.2.3", "@oven/bun-darwin-x64-baseline": "1.2.3", "@oven/bun-linux-aarch64": "1.2.3", "@oven/bun-linux-aarch64-musl": "1.2.3", "@oven/bun-linux-x64": "1.2.3", "@oven/bun-linux-x64-baseline": "1.2.3", "@oven/bun-linux-x64-musl": "1.2.3", "@oven/bun-linux-x64-musl-baseline": "1.2.3", "@oven/bun-windows-x64": "1.2.3", "@oven/bun-windows-x64-baseline": "1.2.3" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bun.exe" } }, "sha512-i8F+I3wxmt9omnLh3lOg0APQtqxZaMU0x6xuC+9YH4Eg/7fu8SM4YbdwyvV8Z6sy3OMtGFB0SYOjU2STuId4FQ=="], - "bun-types": ["bun-types@1.2.3", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-P7AeyTseLKAvgaZqQrvp3RqFM3yN9PlcLuSTe7SoJOfZkER73mLdT2vEQi8U64S1YvM/ldcNiQjn0Sn7H9lGgg=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], diff --git a/src/chat.ts b/src/chat.ts new file mode 100644 index 0000000..415e663 --- /dev/null +++ b/src/chat.ts @@ -0,0 +1,90 @@ +import { ApiKeyNotSetError, debug, getSettings, type CommonOpts } from './common'; +import { withStdinAudioPlayer } from './play_audio'; +import { spawn } from 'child_process'; + +export type ChatOpts = CommonOpts & { + configId: string; +}; + +export class Evi { + getSettings = getSettings; + + private ffmpegArgs(): string[] { + if (process.platform === 'win32') { + return ['-f', 'dshow', '-i', 'audio=default']; + } + if (process.platform === 'darwin') { + return ['-f', 'avfoundation', '-i', ':0']; + } + return ['-f', 'alsa', '-i', 'default']; + } + + async chat(opts: ChatOpts) { + const { hume, reporter } = await this.getSettings(opts); + if (!hume) { + throw new ApiKeyNotSetError(); + } + + reporter.info('Connecting to EVI...'); + debug('Connecting with config %s', opts.configId); + const socket = hume.empathicVoice.chat.connect({ + configId: opts.configId, + debug: !!opts.debug, + }); + + await withStdinAudioPlayer(null, async (writeAudio) => { + const rec = spawn('ffmpeg', [ + ...this.ffmpegArgs(), + '-ac', + '1', + '-ar', + '16000', + '-f', + 's16le', + '-acodec', + 'pcm_s16le', + '-', + ], { stdio: ['ignore', 'pipe', 'ignore'] }); + + socket.on('message', (message) => { + debug('message: %s', message.type); + if (message.type === 'audio_output') { + const data = Buffer.from(message.data, 'base64'); + writeAudio(data); + } + if (message.type === 'assistant_message') { + reporter.info(message.message.content || ''); + } + }); + + socket.on('open', () => { + reporter.info('Connected'); + socket.sendSessionSettings({ + audio: { channels: 1, encoding: 'linear16', sampleRate: 16000 }, + }); + }); + + socket.on('close', () => { + reporter.info('Connection closed'); + rec.kill('SIGINT'); + }); + + socket.on('error', (err) => { + reporter.warn('Socket error: ' + err.message); + }); + + rec.stdout.on('data', (data: Buffer) => { + socket.sendAudioInput({ data: data.toString('base64') }); + }); + + process.on('SIGINT', () => { + rec.kill('SIGINT'); + socket.close(); + process.exit(); + }); + + await socket.tillSocketOpen(); + await new Promise((resolve) => socket.on('close', resolve)); + }); + } +} diff --git a/src/index.ts b/src/index.ts index 0b43b14..f79ab70 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ const usageDescriptions = { 'tts.streaming': 'Use streaming mode for TTS generation (default: true)', 'tts.instantMode': 'Enable ultra-low latency mode for significantly faster generation (requires streaming=true, a voice, and incurs 10% higher cost)', + 'chat.configId': 'ID of the EVI configuration to use', apiKey: 'Override the default API key', json: 'Output in JSON format', pretty: 'Output in human-readable format', @@ -34,6 +35,7 @@ const usageDescriptions = { import { Tts } from './tts'; import * as t from 'typanion'; import { Voices } from './voices'; +import { Evi } from './chat'; import { configValidators, endSession, @@ -461,6 +463,31 @@ class TtsCommand extends Command { } } +class EviChatCommand extends Command { + static paths = [['chat']]; + static usage = Command.Usage({ + description: 'Start an EVI chat', + details: "Connects to Hume's Empathic Voice Interface using the provided configuration ID.", + examples: [['Start a chat', 'chat ']], + }); + + configId = Option.String({ + required: true, + name: 'config-id', + description: usageDescriptions['chat.configId'], + }); + apiKey = Option.String('--api-key', { description: usageDescriptions.apiKey }); + baseUrl = Option.String('--base-url', { + description: 'Override the default API base URL (for testing purposes)', + }); + debug = Option.Boolean('--debug'); + + async execute() { + const evi = new Evi(); + await evi.chat(this); + } +} + // Root command that shows help by default class RootCommand extends Command { static paths = [Command.Default]; @@ -483,7 +510,9 @@ class HelpCommand extends Builtins.HelpCommand { * \`hume voices list --help\` - List available voices * \`hume voices delete --help\` - Delete a saved voice - + + * \`hume chat --help\` - Interact with EVI using your microphone + * \`hume session --help\` - Save settings temporarily so you don't have to repeat yourself * \`hume config --help\` - Save settings more permanently @@ -509,6 +538,7 @@ class LoginCommand extends Command { cli.register(RootCommand); cli.register(TtsCommand); +cli.register(EviChatCommand); cli.register(LoginCommand); cli.register(SessionRootCommand); cli.register(SaveVoiceCommand);