Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ pairux login # Optional: label your agents as yours (browser
pairux join ABC123 --name Claude # Join with the session's join code
pairux listen --json # Follow chat and who is here; keeps the agent present
pairux say "Tests pass" # Post in the chat
pairux voice join --json # Hear call audio; stdin speaks through C0mpute TTS
pairux who # Who is in the session
pairux leave
```
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export default defineConfig({
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
'agent-voice': resolve(__dirname, 'src/renderer/agent-voice.html'),
},
},
},
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/public/agent-voice-worklet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class PairuxVoiceCaptureProcessor extends AudioWorkletProcessor {
process(inputs, outputs) {
const output = outputs[0]?.[0];
output?.fill(0);

const input = inputs[0]?.[0];
if (input?.length) {
const samples = input.slice();
this.port.postMessage(samples, [samples.buffer]);
}
return true;
}
}

registerProcessor('pairux-voice-capture', PairuxVoiceCaptureProcessor);
18 changes: 18 additions & 0 deletions apps/desktop/src/main/agent-cli/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ export interface JoinResult {
owned: boolean;
}

export interface AgentVoiceToken {
token: string;
url: string;
roomName: string;
iceServers?: {
urls: string | string[];
username?: string;
credential?: string;
}[];
}

export class ApiError extends Error {
constructor(
readonly status: number,
Expand Down Expand Up @@ -133,6 +144,13 @@ export class PairuxClient {
});
}

voiceToken(participantId: string) {
return this.request<AgentVoiceToken>(
`/api/v1/agents/${encodeURIComponent(participantId)}/voice-token`,
{ method: 'POST', body: '{}' }
);
}

/** Look up a live session by its 6-character join code. */
lookupJoinCode(joinCode: string) {
return this.request<{ id: string; status?: string; scheduled?: boolean }>(
Expand Down
73 changes: 70 additions & 3 deletions apps/desktop/src/main/agent-cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* pairux join ABC123 --name "Claude"
* pairux listen follow chat + roster (stays present)
* pairux say "Tests pass on my side"
* pairux voice join --json hear call audio and speak through C0mpute
* pairux who who is in the session
* pairux leave
* pairux room link to a fresh anonymous E2EE room
Expand All @@ -30,6 +31,7 @@ import {
revoke,
} from './auth';
import { ApiError, PairuxClient, type ChatMessage, type RosterEntry } from './api';
import type { AgentVoiceRunOptions } from './voice-protocol';
import { newRoomLink } from './room';
import { COPILOT_HELP, runCopilot } from './copilot';
import { UsageError } from './errors';
Expand All @@ -49,6 +51,8 @@ export interface Deps {
maxPolls?: number;
/** Tests stop `copilot watch` after N stream connections; production reconnects. */
maxStreams?: number;
/** Starts the hidden WebRTC renderer used by `pairux voice join`. */
startVoice?: (options: AgentVoiceRunOptions) => Promise<number>;
login?: typeof listenForCallback;
/** The app version, for the User-Agent and `pairux version`. */
version?: string;
Expand All @@ -72,6 +76,8 @@ Usage:
pairux who [--json] List who is in the session
pairux status [--json] Show the agent this CLI is running as
pairux leave Leave the session
pairux voice join [--json] Join call audio; stdout is transcripts, stdin speaks
Each input line is spoken via C0mpute TTS. Send /quit to leave.
pairux room Print a link to a fresh anonymous room: end-to-end
encrypted voice/video, no account needed to join
pairux redeem <CODE> Redeem a credit code (adds days of a paid plan)
Expand All @@ -86,9 +92,16 @@ Environment:
PAIRUX_AGENT_NAME default display name for join
PAIRUX_AGENT_CLIENT default --client for join
PAIRUX_CONFIG_DIR where config.json lives (default ~/.config/pairux)
C0MPUTE_SPEECH_URL speech gateway; default http://127.0.0.1:8787
C0MPUTE_SPEECH_TOKEN token used by \`c0mpute speech serve\` (required for voice)
C0MPUTE_TTS_ENGINE default kokoro
C0MPUTE_TTS_VOICE default af_sarah
C0MPUTE_TTS_LOCALE default en-US
C0MPUTE_STT_LANGUAGE optional language hint, e.g. en

Agents watch and chat. They cannot see or control anyone's screen, and the
host can remove them at any time. Docs: https://pairux.com/docs/agents`;
Agents can follow chat and call audio, and speak into the call. They cannot see
or control anyone's screen, and the host can remove them at any time.
Docs: https://pairux.com/docs/agents`;

function defaultName(env: NodeJS.ProcessEnv): string {
if (env.PAIRUX_AGENT_NAME) return env.PAIRUX_AGENT_NAME;
Expand Down Expand Up @@ -315,7 +328,7 @@ export async function run(argv: string[], deps: Deps): Promise<number> {
deps.out(
`Joined ${joinCode} as ${joined.displayName}${joined.owned ? ' (your agent)' : ' (anonymous agent)'}.`
);
deps.out('Run `pairux listen` to follow the chat and stay present.');
deps.out('Run `pairux listen` for chat or `pairux voice join` to join call audio.');
return 0;
}

Expand All @@ -335,6 +348,60 @@ export async function run(argv: string[], deps: Deps): Promise<number> {
return 0;
}

case 'voice': {
const [action, ...voiceArgs] = rest;
if (!action || ['help', '--help', '-h'].includes(action)) {
deps.out(
'Usage: pairux voice join [--json]\nJoin the active call as this agent. Each stdin line is spoken with C0mpute TTS; transcript events go to stdout. Send /quit to leave.'
);
return 0;
}
if (action !== 'join') throw new UsageError(`Unknown voice command "${action}".`);
const { values, positionals } = parseArgs({
args: voiceArgs,
allowPositionals: true,
options: { json: { type: 'boolean', default: false } },
});
if (positionals.length > 0) throw new UsageError('Usage: pairux voice join [--json]');
if (!deps.startVoice) {
throw new UsageError(
'The PairUX desktop CLI voice runtime is unavailable in this build.'
);
}

const agent = requireAgent();
const speechToken = deps.env.C0MPUTE_SPEECH_TOKEN;
if (!speechToken) {
throw new UsageError(
'Set C0MPUTE_SPEECH_TOKEN to the token used by `c0mpute speech serve` before joining voice.'
);
}
const livekit = await client.voiceToken(agent.participantId);
return await deps.startVoice({
participantId: agent.participantId,
displayName: agent.displayName,
token: livekit.token,
url: livekit.url,
...(livekit.iceServers ? { iceServers: livekit.iceServers } : {}),
speechUrl: deps.env.C0MPUTE_SPEECH_URL ?? 'http://127.0.0.1:8787',
speechToken,
ttsEngine: deps.env.C0MPUTE_TTS_ENGINE ?? 'kokoro',
ttsVoice: deps.env.C0MPUTE_TTS_VOICE ?? 'af_sarah',
ttsLocale: deps.env.C0MPUTE_TTS_LOCALE ?? 'en-US',
sttModel: 'base',
...(deps.env.C0MPUTE_STT_LANGUAGE ? { language: deps.env.C0MPUTE_STT_LANGUAGE } : {}),
json: values.json,
out: deps.out,
err: deps.err,
heartbeat: async () => {
const result = await client.poll(agent.participantId, new Date().toISOString());
if (result.session.status === 'ended') {
throw new ApiError(410, 'The session has ended');
}
},
});
}

case 'who':
case 'status': {
const { values } = parseArgs({
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/main/agent-cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* `pairux login|logout|whoami|join|listen|say|who|status|leave|room|redeem|invites|copilot`
* `pairux login|logout|whoami|join|listen|say|who|status|leave|voice|room|redeem|invites|copilot`
*
* The installed `pairux` launcher passes any argument it does not handle
* itself (update, uninstall, --help, --version) straight to the app, the same
Expand All @@ -21,6 +21,7 @@ export const AGENT_COMMANDS = new Set([
'who',
'status',
'leave',
'voice',
'room',
'redeem',
'invites',
Expand Down Expand Up @@ -56,6 +57,10 @@ export function runAgentCommand(args: string[], version: string): Promise<number
openBrowser,
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
readStdin: readAllStdin,
startVoice: async (options) => {
const { runAgentVoice } = await import('./voice-runtime');
return runAgentVoice(options);
},
version,
});
}
24 changes: 24 additions & 0 deletions apps/desktop/src/main/agent-cli/voice-protocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export interface VoiceIceServer {
urls: string | string[];
username?: string;
credential?: string;
}

export interface AgentVoiceRunOptions {
participantId: string;
displayName: string;
token: string;
url: string;
iceServers?: VoiceIceServer[];
speechUrl: string;
speechToken: string;
ttsEngine: string;
ttsVoice: string;
ttsLocale: string;
sttModel: string;
language?: string;
json: boolean;
out: (line: string) => void;
err: (line: string) => void;
heartbeat: () => Promise<void>;
}
Loading
Loading