Skip to content

Commit ca4946c

Browse files
authored
Merge pull request #279 from CambrianTech/feature/avatar-emotive-rendering
Emotive avatar rendering: expressions, gestures, bone-based eye gaze
2 parents 7cc48fb + e8f699d commit ca4946c

File tree

24 files changed

+2772
-209
lines changed

24 files changed

+2772
-209
lines changed
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* Live Export Command - Server Implementation
3+
*
4+
* Exports recent utterances from the active live voice session
5+
* to markdown format. Mirrors collaboration/chat/export.
6+
*/
7+
8+
import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase';
9+
import type { JTAGContext } from '@system/core/types/JTAGTypes';
10+
import { transformPayload } from '@system/core/types/JTAGTypes';
11+
import { getTSVoiceOrchestrator } from '@system/voice/server';
12+
import { extractSentiment, formatEmotionLabel } from '@system/rag/shared/TextSentiment';
13+
import type { LiveExportParams, LiveExportResult } from '../shared/LiveExportTypes';
14+
import * as fs from 'fs';
15+
import * as path from 'path';
16+
17+
export class LiveExportServerCommand extends CommandBase<LiveExportParams, LiveExportResult> {
18+
19+
constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) {
20+
super('collaboration/live/export', context, subpath, commander);
21+
}
22+
23+
async execute(params: LiveExportParams): Promise<LiveExportResult> {
24+
const orchestrator = getTSVoiceOrchestrator();
25+
26+
// Auto-discover active session
27+
const sessionId = params.callSessionId ?? orchestrator.activeSessionId;
28+
if (!sessionId) {
29+
return transformPayload(params, {
30+
success: false,
31+
message: 'No active live session.',
32+
utteranceCount: 0,
33+
callSessionId: '',
34+
});
35+
}
36+
37+
const limit = params.limit ?? 20;
38+
const includeEmotions = params.includeEmotions ?? true;
39+
40+
// Get utterances from VoiceOrchestrator's in-memory session context
41+
const utterances = orchestrator.getRecentUtterances(sessionId, limit);
42+
const participants = orchestrator.getParticipants(sessionId);
43+
44+
if (utterances.length === 0) {
45+
const markdown = '# Live Call Transcript\n\nNo utterances yet.\n';
46+
console.log(markdown);
47+
return transformPayload(params, {
48+
success: true,
49+
message: 'No utterances in session',
50+
utteranceCount: 0,
51+
markdown,
52+
callSessionId: sessionId,
53+
});
54+
}
55+
56+
// Generate markdown
57+
const markdown = this.generateMarkdown(utterances, participants, includeEmotions, sessionId);
58+
59+
// Write to file or return as string
60+
if (params.output) {
61+
const filepath = path.resolve(params.output);
62+
const dir = path.dirname(filepath);
63+
if (!fs.existsSync(dir)) {
64+
fs.mkdirSync(dir, { recursive: true });
65+
}
66+
fs.writeFileSync(filepath, markdown, 'utf-8');
67+
console.log(`Exported ${utterances.length} utterances to ${filepath}`);
68+
69+
return transformPayload(params, {
70+
success: true,
71+
message: `Exported ${utterances.length} utterances to ${filepath}`,
72+
utteranceCount: utterances.length,
73+
filepath,
74+
callSessionId: sessionId,
75+
});
76+
}
77+
78+
console.log(markdown);
79+
return transformPayload(params, {
80+
success: true,
81+
message: `Exported ${utterances.length} utterances`,
82+
utteranceCount: utterances.length,
83+
markdown,
84+
callSessionId: sessionId,
85+
});
86+
}
87+
88+
private generateMarkdown(
89+
utterances: Array<{
90+
sessionId: string;
91+
speakerId: string;
92+
speakerName: string;
93+
speakerType: 'human' | 'persona' | 'agent';
94+
transcript: string;
95+
confidence: number;
96+
timestamp: number;
97+
}>,
98+
participants: Array<{ userId: string; displayName: string; type: string }>,
99+
includeEmotions: boolean,
100+
sessionId: string,
101+
): string {
102+
const lines: string[] = [];
103+
104+
// Header
105+
lines.push('# Live Call Transcript');
106+
lines.push('');
107+
lines.push(`Session: ${sessionId}`);
108+
lines.push(`Exported: ${new Date().toISOString()}`);
109+
lines.push(`Utterances: ${utterances.length}`);
110+
lines.push(`Participants: ${participants.map(p => p.displayName).join(', ')}`);
111+
lines.push('');
112+
lines.push('---');
113+
lines.push('');
114+
115+
// Utterances
116+
for (const u of utterances) {
117+
const timestamp = new Date(u.timestamp).toLocaleString();
118+
const speakerLabel = this.getSpeakerLabel(u.speakerType);
119+
120+
let emotionTag = '';
121+
if (includeEmotions) {
122+
const sentiment = extractSentiment(u.transcript);
123+
const label = formatEmotionLabel(sentiment);
124+
if (label) emotionTag = ` (${label})`;
125+
}
126+
127+
lines.push(`**${speakerLabel} ${u.speakerName}${emotionTag}** — *${timestamp}*`);
128+
lines.push('');
129+
lines.push(u.transcript);
130+
lines.push('');
131+
lines.push('---');
132+
lines.push('');
133+
}
134+
135+
return lines.join('\n');
136+
}
137+
138+
private getSpeakerLabel(type: string): string {
139+
switch (type) {
140+
case 'human': return '[HUMAN]';
141+
case 'persona': return '[AI]';
142+
case 'agent': return '[AGENT]';
143+
default: return '[UNKNOWN]';
144+
}
145+
}
146+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Live Export Command
3+
* Export recent utterances from the active live voice session.
4+
* Mirrors collaboration/chat/export but for voice sessions.
5+
*
6+
* Usage:
7+
* ./jtag collaboration/live/export
8+
* ./jtag collaboration/live/export --limit=20
9+
* ./jtag collaboration/live/export --output="/tmp/call-transcript.md"
10+
*/
11+
12+
import type { CommandParams, CommandResult, CommandInput } from '@system/core/types/JTAGTypes';
13+
import { Commands } from '@system/core/shared/Commands';
14+
15+
export interface LiveExportParams extends CommandParams {
16+
/** Number of utterances to export (default: 20) */
17+
limit?: number;
18+
19+
/** Output file path (optional — prints to stdout if not provided) */
20+
output?: string;
21+
22+
/** Explicit session ID (auto-discovered if not provided) */
23+
callSessionId?: string;
24+
25+
/** Include emotional annotations (default: true) */
26+
includeEmotions?: boolean;
27+
}
28+
29+
export interface LiveExportResult extends CommandResult {
30+
success: boolean;
31+
message: string;
32+
33+
/** Number of utterances exported */
34+
utteranceCount: number;
35+
36+
/** Markdown content (if output not specified) */
37+
markdown?: string;
38+
39+
/** Output file path (if output specified) */
40+
filepath?: string;
41+
42+
/** Session ID exported from */
43+
callSessionId: string;
44+
}
45+
46+
/**
47+
* LiveExport — Type-safe command executor
48+
*
49+
* Usage:
50+
* import { LiveExport } from '@commands/collaboration/live/export/shared/LiveExportTypes';
51+
* const result = await LiveExport.execute({ limit: 20 });
52+
*/
53+
export const LiveExport = {
54+
execute(params?: CommandInput<LiveExportParams>): Promise<LiveExportResult> {
55+
return Commands.execute<LiveExportParams, LiveExportResult>('collaboration/live/export', params as Partial<LiveExportParams>);
56+
},
57+
commandName: 'collaboration/live/export' as const,
58+
} as const;

src/commands/collaboration/live/join/server/LiveJoinServerCommand.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { Events } from '@system/core/shared/Events';
1818
import type { DataListParams, DataListResult } from '@commands/data/list/shared/DataListTypes';
1919
import type { DataCreateParams, DataCreateResult } from '@commands/data/create/shared/DataCreateTypes';
2020
import type { DataUpdateParams, DataUpdateResult } from '@commands/data/update/shared/DataUpdateTypes';
21-
import { getVoiceOrchestrator } from '@system/voice/server';
21+
import { getVoiceOrchestrator, getTSVoiceOrchestrator } from '@system/voice/server';
2222
import { LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET } from '@shared/AudioConstants';
2323
import { getSecret } from '@system/secrets/SecretManager';
2424

@@ -66,6 +66,13 @@ export class LiveJoinServerCommand extends LiveJoinCommand {
6666
// 3. Find or create active call for this room (with retry logic for race conditions)
6767
const { call, existed } = await this.findOrCreateCall(room, params);
6868

69+
// 3b. Sync late joiners — add room members who came online after the call was created.
70+
// Persona initialization is batched (6 at a time), so some personas come online
71+
// after the call exists. Every join sweeps them in. No hard limits.
72+
if (existed) {
73+
await this.syncLateJoiners(call, room, params);
74+
}
75+
6976
// 4. Add current user as participant (if not already in the call)
7077
const myParticipant = call.addParticipant(
7178
user.id,
@@ -86,6 +93,11 @@ export class LiveJoinServerCommand extends LiveJoinCommand {
8693
const allParticipantIds = call.getActiveParticipants().map(p => p.userId);
8794
await getVoiceOrchestrator().registerSession(call.id, room.id, allParticipantIds);
8895

96+
// Also register with TS VoiceOrchestrator for session context tracking.
97+
// In Rust voice mode, the Rust bridge handles routing but TS tracks utterance
98+
// history for RAG context (VoiceConversationSource) and live/export.
99+
await getTSVoiceOrchestrator().registerSession(call.id, room.id, allParticipantIds);
100+
89101
// 8. Generate LiveKit access token for WebRTC connection
90102
const livekitToken = await this.generateLiveKitToken(
91103
call.id,
@@ -359,6 +371,41 @@ export class LiveJoinServerCommand extends LiveJoinCommand {
359371
return await token.toJwt();
360372
}
361373

374+
/**
375+
* Sync late joiners — when joining an existing call, add room members who are
376+
* now online but weren't in the call yet.
377+
*
378+
* Persona initialization is batched, so personas come online over time.
379+
* This catches up any that arrived after the call was created.
380+
* No hard limits — all online members join.
381+
*/
382+
private async syncLateJoiners(call: CallEntity, room: RoomEntity, params: LiveJoinParams): Promise<void> {
383+
if (!room.members || room.members.length === 0) return;
384+
385+
const memberIds = room.members.map(m => m.userId);
386+
const membersInfo = await this.lookupUsers(memberIds, params);
387+
388+
let addedCount = 0;
389+
for (const memberUser of membersInfo) {
390+
const isHuman = memberUser.type === 'human';
391+
const isOnline = memberUser.status === 'online';
392+
const alreadyInCall = call.getActiveParticipants().some(p => p.userId === memberUser.id);
393+
394+
if (!alreadyInCall && (isHuman || isOnline)) {
395+
call.addParticipant(
396+
memberUser.id as UUID,
397+
memberUser.displayName || memberUser.uniqueId,
398+
memberUser.avatar
399+
);
400+
addedCount++;
401+
}
402+
}
403+
404+
if (addedCount > 0) {
405+
console.log(`🎙️ LiveJoin: Synced ${addedCount} late joiner(s) to existing call ${call.id.slice(0, 8)}`);
406+
}
407+
}
408+
362409
/**
363410
* Look up user info for a list of user IDs
364411
*/
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Live Send Command - Server Implementation
3+
*
4+
* Sends a text message into the active live voice session.
5+
* Auto-discovers session and speaker identity.
6+
* Mirrors collaboration/chat/send but for voice sessions.
7+
*/
8+
9+
import { CommandBase, type ICommandDaemon } from '@daemons/command-daemon/shared/CommandBase';
10+
import type { JTAGContext } from '@system/core/types/JTAGTypes';
11+
import { transformPayload } from '@system/core/types/JTAGTypes';
12+
import { getTSVoiceOrchestrator } from '@system/voice/server';
13+
import { CollaborationLiveTranscription } from '../../transcription/shared/CollaborationLiveTranscriptionTypes';
14+
import type { LiveSendParams, LiveSendResult } from '../shared/LiveSendTypes';
15+
import { Commands } from '@system/core/shared/Commands';
16+
import type { DataListResult } from '@commands/data/list/shared/DataListTypes';
17+
import { DataList } from '@commands/data/list/shared/DataListTypes';
18+
import type { UserEntity } from '@system/data/entities/UserEntity';
19+
20+
export class LiveSendServerCommand extends CommandBase<LiveSendParams, LiveSendResult> {
21+
22+
constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) {
23+
super('collaboration/live/send', context, subpath, commander);
24+
}
25+
26+
async execute(params: LiveSendParams): Promise<LiveSendResult> {
27+
const orchestrator = getTSVoiceOrchestrator();
28+
29+
// Auto-discover active session
30+
const sessionId = params.callSessionId ?? orchestrator.activeSessionId;
31+
if (!sessionId) {
32+
return transformPayload(params, {
33+
success: false,
34+
message: 'No active live session. Start a call first.',
35+
callSessionId: '',
36+
responderCount: 0,
37+
});
38+
}
39+
40+
// Resolve speaker identity
41+
const speakerId = params.speakerId ?? params.userId ?? '';
42+
let speakerName = params.speakerName ?? '';
43+
44+
if (!speakerName && speakerId) {
45+
try {
46+
const result = await DataList.execute<UserEntity>({
47+
collection: 'users',
48+
filter: { id: speakerId },
49+
limit: 1,
50+
dbHandle: 'default',
51+
});
52+
if (result.success && result.items?.length) {
53+
speakerName = result.items[0].displayName || result.items[0].uniqueId;
54+
}
55+
} catch {
56+
// Fall through to default
57+
}
58+
}
59+
if (!speakerName) speakerName = 'CLI';
60+
61+
// Route through the existing transcription command
62+
const transcriptionResult = await CollaborationLiveTranscription.execute({
63+
callSessionId: sessionId,
64+
speakerId,
65+
speakerName,
66+
transcript: params.message,
67+
confidence: params.confidence ?? 1.0,
68+
language: 'en',
69+
timestamp: Date.now(),
70+
});
71+
72+
const responderCount = transcriptionResult.success
73+
? parseInt(transcriptionResult.message.match(/(\d+) AI/)?.[1] ?? '0', 10)
74+
: 0;
75+
76+
return transformPayload(params, {
77+
success: transcriptionResult.success,
78+
message: transcriptionResult.success
79+
? `Sent to live session → ${responderCount} AI responders`
80+
: `Failed: ${transcriptionResult.message}`,
81+
callSessionId: sessionId,
82+
responderCount,
83+
});
84+
}
85+
}

0 commit comments

Comments
 (0)