feat(voice): on-demand PodMan voice test + Hermes notify + TTS tuning
Snapshot of in-progress voice work, committed to unblock concurrent edits: speakInRoom + POST voice-test endpoint, Test PodMan voice button (PodView/api), Hermes notify action + scripts/hermes-notify.mjs, agent exits on LiveKit disconnect for auto-restart, TTS playback tuning (subscriber-ready delay, preroll/tail silence, fallback line, microphone source), verify script updates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
import type { Room } from '@livekit/rtc-node';
|
import type { Room } from '@livekit/rtc-node';
|
||||||
|
import { Room as LiveKitRoom } from '@livekit/rtc-node';
|
||||||
|
import { AccessToken } from 'livekit-server-sdk';
|
||||||
import type { Collision, DataMessage, HermesMessage, Intervention } from '@podman/shared';
|
import type { Collision, DataMessage, HermesMessage, Intervention } from '@podman/shared';
|
||||||
import { DATA_TOPIC } from '@podman/shared';
|
import { DATA_TOPIC } from '@podman/shared';
|
||||||
|
import { env } from '../env.js';
|
||||||
|
import { speak } from '../voice/live.js';
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
@@ -37,3 +41,52 @@ export async function publishHermesMessage(
|
|||||||
topic: DATA_TOPIC,
|
topic: DATA_TOPIC,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function publishHermesIntervention(
|
||||||
|
room: Room,
|
||||||
|
collision: Collision,
|
||||||
|
intervention: Intervention,
|
||||||
|
voiceLine?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const data: DataMessage = { type: 'COLLISION', collision, intervention };
|
||||||
|
await room.localParticipant?.publishData(encoder.encode(JSON.stringify(data)), {
|
||||||
|
reliable: true,
|
||||||
|
topic: DATA_TOPIC,
|
||||||
|
});
|
||||||
|
await publishHermesMessage(room, collision, intervention);
|
||||||
|
if (voiceLine) await speak(room, voiceLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hermesToken(roomName: string): Promise<string> {
|
||||||
|
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
||||||
|
identity: `podman-hermes-${Date.now()}`,
|
||||||
|
name: 'PodMan Hermes',
|
||||||
|
ttl: '10m',
|
||||||
|
});
|
||||||
|
at.addGrant({
|
||||||
|
roomJoin: true,
|
||||||
|
room: roomName,
|
||||||
|
canPublish: true,
|
||||||
|
canSubscribe: true,
|
||||||
|
canPublishData: true,
|
||||||
|
});
|
||||||
|
return at.toJwt();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyHermesInterventionInRoom(
|
||||||
|
roomName: string,
|
||||||
|
collision: Collision,
|
||||||
|
intervention: Intervention,
|
||||||
|
voiceLine?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const room = new LiveKitRoom();
|
||||||
|
try {
|
||||||
|
await room.connect(env.LIVEKIT_URL, await hermesToken(roomName), {
|
||||||
|
autoSubscribe: false,
|
||||||
|
dynacast: false,
|
||||||
|
});
|
||||||
|
await publishHermesIntervention(room, collision, intervention, voiceLine);
|
||||||
|
} finally {
|
||||||
|
await room.disconnect().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -173,6 +173,12 @@ async function main() {
|
|||||||
await withTimeout(dispose(), SHUTDOWN_GRACE_MS);
|
await withTimeout(dispose(), SHUTDOWN_GRACE_MS);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
room.on(RoomEvent.Disconnected, () => {
|
||||||
|
if (!shuttingDown) {
|
||||||
|
console.error('[agent] LiveKit disconnected; exiting so systemd restarts the worker');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
process.on('SIGINT', shutdown);
|
process.on('SIGINT', shutdown);
|
||||||
process.on('SIGTERM', shutdown);
|
process.on('SIGTERM', shutdown);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { RoomEvent, type Room } from '@livekit/rtc-node';
|
import { RoomEvent, type Room } from '@livekit/rtc-node';
|
||||||
import type { EngineerContext, Collision, Intervention, DataMessage } from '@podman/shared';
|
import type { EngineerContext, Collision, Intervention, DataMessage } from '@podman/shared';
|
||||||
import { DATA_TOPIC } from '@podman/shared';
|
|
||||||
import { analyzeFrame } from '../vision/gemini.js';
|
import { analyzeFrame } from '../vision/gemini.js';
|
||||||
import { detectCollisions } from '../collision/detector.js';
|
import { detectCollisions } from '../collision/detector.js';
|
||||||
import { getGithubState } from '../github/client.js';
|
import { getGithubState } from '../github/client.js';
|
||||||
@@ -13,12 +12,10 @@ import {
|
|||||||
import { getGitStates } from '../memory/db.js';
|
import { getGitStates } from '../memory/db.js';
|
||||||
import { recallSimilar } from '../memory/vectors.js';
|
import { recallSimilar } from '../memory/vectors.js';
|
||||||
import { shouldIntervene, preferredAction } from '../memory/policy.js';
|
import { shouldIntervene, preferredAction } from '../memory/policy.js';
|
||||||
import { speak } from '../voice/live.js';
|
import { publishHermesIntervention } from '../action/hermes.js';
|
||||||
import { publishHermesMessage } from '../action/hermes.js';
|
|
||||||
|
|
||||||
export class PodMan {
|
export class PodMan {
|
||||||
private contexts = new Map<string, EngineerContext>();
|
private contexts = new Map<string, EngineerContext>();
|
||||||
private encoder = new TextEncoder();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private room: Room,
|
private room: Room,
|
||||||
@@ -82,7 +79,7 @@ export class PodMan {
|
|||||||
(prior ? ' Seen before.' : '');
|
(prior ? ' Seen before.' : '');
|
||||||
|
|
||||||
// Spoken line stays short, but uses natural phrasing for Gemini TTS prosody.
|
// Spoken line stays short, but uses natural phrasing for Gemini TTS prosody.
|
||||||
const voiceLine = `Heads up. ${names} are both editing ${shortFile}. Please sync before pushing.`;
|
const voiceLine = `${names} are both editing ${shortFile}. Please sync before pushing.`;
|
||||||
|
|
||||||
const intervention: Intervention = {
|
const intervention: Intervention = {
|
||||||
id: `int_${Date.now()}`,
|
id: `int_${Date.now()}`,
|
||||||
@@ -103,12 +100,11 @@ export class PodMan {
|
|||||||
};
|
};
|
||||||
await recordIntervention(intervention);
|
await recordIntervention(intervention);
|
||||||
|
|
||||||
const data: DataMessage = { type: 'COLLISION', collision, intervention };
|
await publishHermesIntervention(
|
||||||
await this.room.localParticipant?.publishData(this.encoder.encode(JSON.stringify(data)), {
|
this.room,
|
||||||
reliable: true,
|
collision,
|
||||||
topic: DATA_TOPIC,
|
intervention,
|
||||||
});
|
collision.severity === 'critical' ? voiceLine : undefined,
|
||||||
await publishHermesMessage(this.room, collision, intervention);
|
);
|
||||||
if (collision.severity === 'critical') await speak(this.room, voiceLine);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const env = {
|
|||||||
LIVEKIT_URL: req('LIVEKIT_URL'),
|
LIVEKIT_URL: req('LIVEKIT_URL'),
|
||||||
LIVEKIT_API_KEY: req('LIVEKIT_API_KEY'),
|
LIVEKIT_API_KEY: req('LIVEKIT_API_KEY'),
|
||||||
LIVEKIT_API_SECRET: req('LIVEKIT_API_SECRET'),
|
LIVEKIT_API_SECRET: req('LIVEKIT_API_SECRET'),
|
||||||
|
LIVEKIT_AGENT_NAME: opt('LIVEKIT_AGENT_NAME'),
|
||||||
// Gemini
|
// Gemini
|
||||||
GEMINI_API_KEY: reqAny('GEMINI_API_KEY', ['GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY']),
|
GEMINI_API_KEY: reqAny('GEMINI_API_KEY', ['GOOGLE_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY']),
|
||||||
GEMINI_VISION_MODEL: opt('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),
|
GEMINI_VISION_MODEL: opt('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),
|
||||||
|
|||||||
+121
-5
@@ -1,11 +1,12 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
|
import type { Socket } from 'node:net';
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import { AccessToken, RoomConfiguration } from 'livekit-server-sdk';
|
import { AccessToken, RoomAgentDispatch, RoomConfiguration } from 'livekit-server-sdk';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { createSyncPr } from './github/client.js';
|
import { createSyncPr } from './github/client.js';
|
||||||
import { recordOutcome, memoryStats } from './memory/store.js';
|
import { recordCollision, recordIntervention, recordOutcome, memoryStats } from './memory/store.js';
|
||||||
import { closeMemory, initMemory } from './memory/db.js';
|
import { closeMemory, initMemory } from './memory/db.js';
|
||||||
import {
|
import {
|
||||||
listPods,
|
listPods,
|
||||||
@@ -20,13 +21,27 @@ import {
|
|||||||
import { getPresence, closeRoom } from './livekit/rooms.js';
|
import { getPresence, closeRoom } from './livekit/rooms.js';
|
||||||
import { loadPodGraph, reachFrom } from './graph/store.js';
|
import { loadPodGraph, reachFrom } from './graph/store.js';
|
||||||
import { listPodActivity } from './activity/store.js';
|
import { listPodActivity } from './activity/store.js';
|
||||||
import type { InterventionOutcome } from '@podman/shared';
|
import { speakInRoom } from './voice/live.js';
|
||||||
|
import { notifyHermesInterventionInRoom } from './action/hermes.js';
|
||||||
|
import type { Collision, Intervention, InterventionOutcome, SuggestedActionKind } from '@podman/shared';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.get('/health', (_req, res) => res.json({ ok: true }));
|
app.get('/health', (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
function stringArray(value: unknown): string[] {
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.map((item) => String(item).trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggestedAction(value: unknown): SuggestedActionKind {
|
||||||
|
return value === 'open_sync_pr' || value === 'ping_teammate' || value === 'none'
|
||||||
|
? value
|
||||||
|
: 'ping_teammate';
|
||||||
|
}
|
||||||
|
|
||||||
// Mint a LiveKit token for an engineer joining a pod.
|
// Mint a LiveKit token for an engineer joining a pod.
|
||||||
app.post('/api/token', async (req, res) => {
|
app.post('/api/token', async (req, res) => {
|
||||||
const { room, identity, name, githubLogin } = req.body ?? {};
|
const { room, identity, name, githubLogin } = req.body ?? {};
|
||||||
@@ -38,9 +53,22 @@ app.post('/api/token', async (req, res) => {
|
|||||||
metadata: JSON.stringify({ githubLogin: githubLogin ?? name }),
|
metadata: JSON.stringify({ githubLogin: githubLogin ?? name }),
|
||||||
});
|
});
|
||||||
at.addGrant({ roomJoin: true, room, canPublish: true, canSubscribe: true, canPublishData: true });
|
at.addGrant({ roomJoin: true, room, canPublish: true, canSubscribe: true, canPublishData: true });
|
||||||
|
const agents = env.LIVEKIT_AGENT_NAME
|
||||||
|
? [
|
||||||
|
new RoomAgentDispatch({
|
||||||
|
agentName: env.LIVEKIT_AGENT_NAME,
|
||||||
|
metadata: JSON.stringify({ podId: room }),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: undefined;
|
||||||
// Auto-clean the room: close 60s after it empties, drop a participant 20s
|
// Auto-clean the room: close 60s after it empties, drop a participant 20s
|
||||||
// after they disconnect. Applied when LiveKit auto-creates the room.
|
// after they disconnect. Applied when LiveKit auto-creates the room.
|
||||||
at.roomConfig = new RoomConfiguration({ name: room, emptyTimeout: 60, departureTimeout: 20 });
|
at.roomConfig = new RoomConfiguration({
|
||||||
|
name: room,
|
||||||
|
emptyTimeout: 60,
|
||||||
|
departureTimeout: 20,
|
||||||
|
agents,
|
||||||
|
});
|
||||||
res.json({ token: await at.toJwt(), url: env.LIVEKIT_URL });
|
res.json({ token: await at.toJwt(), url: env.LIVEKIT_URL });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,6 +165,84 @@ app.post('/api/pods/:id/members', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/pods/:id/voice-test', async (req, res) => {
|
||||||
|
const podId = req.params.id;
|
||||||
|
const message =
|
||||||
|
typeof req.body?.message === 'string' && req.body.message.trim()
|
||||||
|
? req.body.message.trim()
|
||||||
|
: 'PodMan voice test. Gemini TTS is playing through LiveKit.';
|
||||||
|
try {
|
||||||
|
await speakInRoom(podId, message);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: (e as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/pods/:id/hermes/notify', async (req, res) => {
|
||||||
|
const podId = req.params.id;
|
||||||
|
const pod = await getPod(podId);
|
||||||
|
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
||||||
|
|
||||||
|
const body = req.body ?? {};
|
||||||
|
const message = typeof body.message === 'string' ? body.message.trim() : '';
|
||||||
|
if (!message) return res.status(400).json({ error: 'message is required' });
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const engineers = stringArray(body.engineers);
|
||||||
|
const recipients = engineers.length ? engineers : pod.members.slice(0, 2);
|
||||||
|
const file = typeof body.file === 'string' && body.file.trim() ? body.file.trim() : 'Hermes signal';
|
||||||
|
const urgent = body.urgency === 'urgent' || body.severity === 'critical';
|
||||||
|
const collision: Collision = {
|
||||||
|
id: typeof body.collisionId === 'string' && body.collisionId ? body.collisionId : `col_${Date.now()}`,
|
||||||
|
podId,
|
||||||
|
file,
|
||||||
|
symbol: typeof body.symbol === 'string' && body.symbol ? body.symbol : undefined,
|
||||||
|
engineers: recipients,
|
||||||
|
severity: urgent ? 'critical' : 'warn',
|
||||||
|
githubState: { unpushed: body.unpushed !== false },
|
||||||
|
detectedAt: now,
|
||||||
|
};
|
||||||
|
const intervention: Intervention = {
|
||||||
|
id:
|
||||||
|
typeof body.interventionId === 'string' && body.interventionId
|
||||||
|
? body.interventionId
|
||||||
|
: `int_${Date.now()}`,
|
||||||
|
collisionId: collision.id,
|
||||||
|
podId,
|
||||||
|
kind: urgent ? 'voice' : 'card',
|
||||||
|
message,
|
||||||
|
suggestedAction: {
|
||||||
|
kind: suggestedAction(body.suggestedAction),
|
||||||
|
params: {
|
||||||
|
file,
|
||||||
|
engineers: recipients,
|
||||||
|
source: 'local-hermes',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
const voiceLine =
|
||||||
|
urgent && body.speak !== false
|
||||||
|
? typeof body.voiceLine === 'string' && body.voiceLine.trim()
|
||||||
|
? body.voiceLine.trim()
|
||||||
|
: message
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await recordCollision(collision);
|
||||||
|
await recordIntervention(intervention);
|
||||||
|
if (body.dryRun === true) {
|
||||||
|
return res.status(202).json({ ok: true, collision, intervention, livekit: 'dry-run' });
|
||||||
|
}
|
||||||
|
await notifyHermesInterventionInRoom(podId, collision, intervention, voiceLine);
|
||||||
|
res.status(202).json({ ok: true, collision, intervention, livekit: 'notified' });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: (e as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.delete('/api/pods/:id/members/:name', async (req, res) => {
|
app.delete('/api/pods/:id/members/:name', async (req, res) => {
|
||||||
const pod = await removeMember(req.params.id, req.params.name);
|
const pod = await removeMember(req.params.id, req.params.name);
|
||||||
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
||||||
@@ -206,6 +312,12 @@ app.get('/api/pods/:id/activity/stream', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const http = createServer(app);
|
const http = createServer(app);
|
||||||
|
const sockets = new Set<Socket>();
|
||||||
|
|
||||||
|
http.on('connection', (socket) => {
|
||||||
|
sockets.add(socket);
|
||||||
|
socket.on('close', () => sockets.delete(socket));
|
||||||
|
});
|
||||||
|
|
||||||
// ws relay: the agent pushes collision/intervention JSON here; PWAs subscribed by pod receive it.
|
// ws relay: the agent pushes collision/intervention JSON here; PWAs subscribed by pod receive it.
|
||||||
const wss = new WebSocketServer({ server: http, path: '/api/events' });
|
const wss = new WebSocketServer({ server: http, path: '/api/events' });
|
||||||
@@ -237,7 +349,11 @@ async function shutdown(signal: NodeJS.Signals): Promise<void> {
|
|||||||
console.log(`[server] ${signal} received; shutting down`);
|
console.log(`[server] ${signal} received; shutting down`);
|
||||||
for (const client of clients) client.close();
|
for (const client of clients) client.close();
|
||||||
wss.close();
|
wss.close();
|
||||||
await new Promise<void>((resolve) => http.close(() => resolve()));
|
for (const socket of sockets) socket.destroy();
|
||||||
|
await Promise.race([
|
||||||
|
new Promise<void>((resolve) => http.close(() => resolve())),
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, 5000)),
|
||||||
|
]);
|
||||||
await closeMemory().catch((e) => console.warn(`[memory] close failed: ${(e as Error).message}`));
|
await closeMemory().catch((e) => console.warn(`[memory] close failed: ${(e as Error).message}`));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,32 @@ import {
|
|||||||
AudioFrame,
|
AudioFrame,
|
||||||
AudioSource,
|
AudioSource,
|
||||||
LocalAudioTrack,
|
LocalAudioTrack,
|
||||||
|
Room,
|
||||||
TrackPublishOptions,
|
TrackPublishOptions,
|
||||||
TrackSource,
|
TrackSource,
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
type Room,
|
|
||||||
} from '@livekit/rtc-node';
|
} from '@livekit/rtc-node';
|
||||||
import { GoogleGenAI, Modality, type LiveServerMessage, type Session } from '@google/genai';
|
import { GoogleGenAI, Modality, type LiveServerMessage, type Session } from '@google/genai';
|
||||||
|
import { AccessToken } from 'livekit-server-sdk';
|
||||||
import { DATA_TOPIC, type DataMessage } from '@podman/shared';
|
import { DATA_TOPIC, type DataMessage } from '@podman/shared';
|
||||||
import { env } from '../env.js';
|
import { env } from '../env.js';
|
||||||
|
|
||||||
const SAMPLE_RATE = 24_000;
|
const SAMPLE_RATE = 24_000;
|
||||||
const CHANNELS = 1;
|
const CHANNELS = 1;
|
||||||
const FRAME_SAMPLES = SAMPLE_RATE / 10;
|
const FRAME_SAMPLES = SAMPLE_RATE / 10;
|
||||||
|
const SUBSCRIBER_READY_MS = 750;
|
||||||
|
const AUDIO_PREROLL_MS = 300;
|
||||||
|
const AUDIO_TAIL_MS = 300;
|
||||||
const VOICE_QUEUE_MS = 30_000;
|
const VOICE_QUEUE_MS = 30_000;
|
||||||
const VOICE_TRACK_PREFIX = 'podman-hermes-voice';
|
const VOICE_TRACK_PREFIX = 'podman-hermes-voice';
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY });
|
const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY });
|
||||||
let voiceQueue: Promise<void> = Promise.resolve();
|
let voiceQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
function ttsPrompt(message: string): string {
|
function ttsPrompt(message: string): string {
|
||||||
return [
|
return [
|
||||||
'Speak this PodMan coordination alert as a calm, natural engineering teammate.',
|
'Speak this PodMan coordination alert as a calm, natural engineering teammate.',
|
||||||
@@ -55,7 +63,8 @@ function audioFrameFromBase64(data: string, mimeType?: string): AudioFrame | nul
|
|||||||
const buf = Buffer.from(data, 'base64');
|
const buf = Buffer.from(data, 'base64');
|
||||||
if (buf.byteLength < 2) return null;
|
if (buf.byteLength < 2) return null;
|
||||||
const bytes = buf.byteLength % 2 === 0 ? buf : buf.subarray(0, buf.byteLength - 1);
|
const bytes = buf.byteLength % 2 === 0 ? buf : buf.subarray(0, buf.byteLength - 1);
|
||||||
const samples = new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 2);
|
const samples = new Int16Array(bytes.byteLength / 2);
|
||||||
|
for (let i = 0; i < samples.length; i += 1) samples[i] = bytes.readInt16LE(i * 2);
|
||||||
return new AudioFrame(samples, SAMPLE_RATE, CHANNELS, samples.length / CHANNELS);
|
return new AudioFrame(samples, SAMPLE_RATE, CHANNELS, samples.length / CHANNELS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +87,7 @@ function framesFromPcmBase64(data: string, mimeType?: string): AudioFrame[] {
|
|||||||
const samples = frame.data;
|
const samples = frame.data;
|
||||||
const frames: AudioFrame[] = [];
|
const frames: AudioFrame[] = [];
|
||||||
for (let offset = 0; offset < samples.length; offset += FRAME_SAMPLES) {
|
for (let offset = 0; offset < samples.length; offset += FRAME_SAMPLES) {
|
||||||
const chunk = samples.subarray(offset, Math.min(offset + FRAME_SAMPLES, samples.length));
|
const chunk = samples.slice(offset, Math.min(offset + FRAME_SAMPLES, samples.length));
|
||||||
frames.push(new AudioFrame(chunk, SAMPLE_RATE, CHANNELS, chunk.length / CHANNELS));
|
frames.push(new AudioFrame(chunk, SAMPLE_RATE, CHANNELS, chunk.length / CHANNELS));
|
||||||
}
|
}
|
||||||
return frames;
|
return frames;
|
||||||
@@ -100,8 +109,24 @@ async function generateTtsFrames(message: string): Promise<AudioFrame[]> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fallbackVoiceLine(message: string): string {
|
||||||
|
const clean = message.replace(/^heads up[.!]?\s*/i, '').trim();
|
||||||
|
if (clean && clean !== message) return clean;
|
||||||
|
return 'PodMan noticed a critical conflict. Please sync with the team before pushing.';
|
||||||
|
}
|
||||||
|
|
||||||
async function speakWithTts(source: AudioSource, message: string): Promise<void> {
|
async function speakWithTts(source: AudioSource, message: string): Promise<void> {
|
||||||
for (const frame of await generateTtsFrames(message)) {
|
let frames: AudioFrame[];
|
||||||
|
try {
|
||||||
|
frames = await generateTtsFrames(message);
|
||||||
|
} catch (err) {
|
||||||
|
const fallback = fallbackVoiceLine(message);
|
||||||
|
console.warn(`[voice] Gemini TTS retrying with fallback line: ${(err as Error).message}`);
|
||||||
|
frames = await generateTtsFrames(fallback);
|
||||||
|
}
|
||||||
|
if (frames.length === 0) throw new Error('Gemini TTS returned no audio frames');
|
||||||
|
console.log(`[voice] publishing Gemini TTS audio frames=${frames.length}`);
|
||||||
|
for (const frame of frames) {
|
||||||
await source.captureFrame(frame);
|
await source.captureFrame(frame);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,6 +175,11 @@ async function waitForVoicePlayout(source: AudioSource): Promise<void> {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function captureSilence(source: AudioSource, durationMs: number): Promise<void> {
|
||||||
|
const samples = Math.max(1, Math.round((SAMPLE_RATE * durationMs) / 1000));
|
||||||
|
await source.captureFrame(new AudioFrame(new Int16Array(samples), SAMPLE_RATE, CHANNELS, samples));
|
||||||
|
}
|
||||||
|
|
||||||
async function speakAudio(room: Room, message: string): Promise<void> {
|
async function speakAudio(room: Room, message: string): Promise<void> {
|
||||||
const localParticipant = room.localParticipant;
|
const localParticipant = room.localParticipant;
|
||||||
if (!localParticipant) return;
|
if (!localParticipant) return;
|
||||||
@@ -157,14 +187,17 @@ async function speakAudio(room: Room, message: string): Promise<void> {
|
|||||||
const source = new AudioSource(SAMPLE_RATE, CHANNELS, VOICE_QUEUE_MS);
|
const source = new AudioSource(SAMPLE_RATE, CHANNELS, VOICE_QUEUE_MS);
|
||||||
const track = LocalAudioTrack.createAudioTrack(`${VOICE_TRACK_PREFIX}-${Date.now()}`, source);
|
const track = LocalAudioTrack.createAudioTrack(`${VOICE_TRACK_PREFIX}-${Date.now()}`, source);
|
||||||
const options = new TrackPublishOptions();
|
const options = new TrackPublishOptions();
|
||||||
options.source = TrackSource.SOURCE_UNKNOWN;
|
options.source = TrackSource.SOURCE_MICROPHONE;
|
||||||
let publicationSid: string | undefined;
|
let publicationSid: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const publication = await localParticipant.publishTrack(track, options);
|
const publication = await localParticipant.publishTrack(track, options);
|
||||||
publicationSid = publication.sid;
|
publicationSid = publication.sid;
|
||||||
|
await delay(SUBSCRIBER_READY_MS);
|
||||||
|
await captureSilence(source, AUDIO_PREROLL_MS);
|
||||||
if (env.GEMINI_LIVE_MODEL.includes('tts')) await speakWithTts(source, message);
|
if (env.GEMINI_LIVE_MODEL.includes('tts')) await speakWithTts(source, message);
|
||||||
else await speakWithLive(source, message);
|
else await speakWithLive(source, message);
|
||||||
|
await captureSilence(source, AUDIO_TAIL_MS);
|
||||||
await waitForVoicePlayout(source);
|
await waitForVoicePlayout(source);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[voice] Gemini voice publish failed: ${(err as Error).message}`);
|
console.warn(`[voice] Gemini voice publish failed: ${(err as Error).message}`);
|
||||||
@@ -188,3 +221,25 @@ export async function speak(room: Room, message: string): Promise<void> {
|
|||||||
voiceQueue = voiceQueue.catch(() => {}).then(() => speakAudio(room, message));
|
voiceQueue = voiceQueue.catch(() => {}).then(() => speakAudio(room, message));
|
||||||
await voiceQueue;
|
await voiceQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function speakInRoom(roomName: string, message: string): Promise<void> {
|
||||||
|
const room = new Room();
|
||||||
|
try {
|
||||||
|
const at = new AccessToken(env.LIVEKIT_API_KEY, env.LIVEKIT_API_SECRET, {
|
||||||
|
identity: `podman-voice-${Date.now()}`,
|
||||||
|
name: 'PodMan voice',
|
||||||
|
ttl: '5m',
|
||||||
|
});
|
||||||
|
at.addGrant({
|
||||||
|
roomJoin: true,
|
||||||
|
room: roomName,
|
||||||
|
canPublish: true,
|
||||||
|
canSubscribe: true,
|
||||||
|
canPublishData: true,
|
||||||
|
});
|
||||||
|
await room.connect(env.LIVEKIT_URL, await at.toJwt());
|
||||||
|
await speak(room, message);
|
||||||
|
} finally {
|
||||||
|
await room.disconnect().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ import {
|
|||||||
WorkflowIcon,
|
WorkflowIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { Room, RemoteTrack, RemoteTrackPublication, RemoteParticipant } from 'livekit-client';
|
import type { Room, RemoteTrack, RemoteTrackPublication } from 'livekit-client';
|
||||||
import type { Pod, PodActivityEvent, PodActivityKind, PodActivitySource } from '@podman/shared';
|
import type { Pod, PodActivityEvent, PodActivityKind, PodActivitySource } from '@podman/shared';
|
||||||
import { useBeat } from '../livekit/useBeat.js';
|
import { useBeat } from '../livekit/useBeat.js';
|
||||||
|
import { testPodVoice } from '../lib/api.js';
|
||||||
import { useInterventions, primeSpeech } from '../livekit/useInterventions.js';
|
import { useInterventions, primeSpeech } from '../livekit/useInterventions.js';
|
||||||
import { usePodActivity } from '../hooks/use-pod-activity.js';
|
import { usePodActivity } from '../hooks/use-pod-activity.js';
|
||||||
import LiveWaveform from '@/components/ruixen/live-waveform';
|
import LiveWaveform from '@/components/ruixen/live-waveform';
|
||||||
@@ -109,6 +110,7 @@ export function PodView({
|
|||||||
}) {
|
}) {
|
||||||
const [participants, setParticipants] = useState<PInfo[]>([]);
|
const [participants, setParticipants] = useState<PInfo[]>([]);
|
||||||
const [sharing, setSharing] = useState(false);
|
const [sharing, setSharing] = useState(false);
|
||||||
|
const [testingVoice, setTestingVoice] = useState(false);
|
||||||
const [note, setNote] = useState<string | null>(null);
|
const [note, setNote] = useState<string | null>(null);
|
||||||
const [audioBlocked, setAudioBlocked] = useState(false);
|
const [audioBlocked, setAudioBlocked] = useState(false);
|
||||||
const [leftStreamOpen, setLeftStreamOpen] = useState(() =>
|
const [leftStreamOpen, setLeftStreamOpen] = useState(() =>
|
||||||
@@ -122,6 +124,7 @@ export function PodView({
|
|||||||
const activity = usePodActivity(team.id, me);
|
const activity = usePodActivity(team.id, me);
|
||||||
|
|
||||||
const audioRef = useRef<HTMLDivElement>(null);
|
const audioRef = useRef<HTMLDivElement>(null);
|
||||||
|
const audioElementsRef = useRef(new Map<string, HTMLElement>());
|
||||||
const screenTrackRef = useRef<MediaStreamTrack | null>(null);
|
const screenTrackRef = useRef<MediaStreamTrack | null>(null);
|
||||||
const onLeaveRef = useRef(onLeave);
|
const onLeaveRef = useRef(onLeave);
|
||||||
onLeaveRef.current = onLeave;
|
onLeaveRef.current = onLeave;
|
||||||
@@ -131,12 +134,34 @@ export function PodView({
|
|||||||
const refresh = () => setParticipants(snapshot(room, me));
|
const refresh = () => setParticipants(snapshot(room, me));
|
||||||
refresh();
|
refresh();
|
||||||
|
|
||||||
const onAudio = (track: RemoteTrack, _pub: RemoteTrackPublication, _p: RemoteParticipant) => {
|
const attachAudio = (track: RemoteTrack, pub: RemoteTrackPublication) => {
|
||||||
if (track.kind === Track.Kind.Audio && audioRef.current) {
|
if (track.kind !== Track.Kind.Audio || !audioRef.current) return;
|
||||||
audioRef.current.appendChild(track.attach());
|
const key = pub.trackSid || track.sid || track.mediaStreamTrack.id;
|
||||||
}
|
if (audioElementsRef.current.has(key)) return;
|
||||||
|
const element = track.attach();
|
||||||
|
element.autoplay = true;
|
||||||
|
audioElementsRef.current.set(key, element);
|
||||||
|
audioRef.current.appendChild(element);
|
||||||
};
|
};
|
||||||
const onAudioGone = (track: RemoteTrack) => track.detach().forEach((el) => el.remove());
|
const removeAudio = (track: RemoteTrack, pub?: RemoteTrackPublication) => {
|
||||||
|
const key = pub?.trackSid || track.sid || track.mediaStreamTrack.id;
|
||||||
|
const attached = audioElementsRef.current.get(key);
|
||||||
|
if (attached) {
|
||||||
|
attached.remove();
|
||||||
|
audioElementsRef.current.delete(key);
|
||||||
|
}
|
||||||
|
track.detach().forEach((el) => el.remove());
|
||||||
|
};
|
||||||
|
const attachExistingAudio = () => {
|
||||||
|
room.remoteParticipants.forEach((participant) => {
|
||||||
|
participant.audioTrackPublications.forEach((publication) => {
|
||||||
|
const track = publication.track;
|
||||||
|
if (track) attachAudio(track, publication);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onAudio = (track: RemoteTrack, pub: RemoteTrackPublication) => attachAudio(track, pub);
|
||||||
|
const onAudioGone = (track: RemoteTrack, pub: RemoteTrackPublication) => removeAudio(track, pub);
|
||||||
const onDisconnected = () => onLeaveRef.current();
|
const onDisconnected = () => onLeaveRef.current();
|
||||||
// Browsers block autoplay of incoming audio until a user gesture unlocks it.
|
// Browsers block autoplay of incoming audio until a user gesture unlocks it.
|
||||||
// Surface a button whenever the room can't play sound so PodMan's voice cues
|
// Surface a button whenever the room can't play sound so PodMan's voice cues
|
||||||
@@ -152,6 +177,7 @@ export function PodView({
|
|||||||
.on(RoomEvent.TrackUnsubscribed, onAudioGone)
|
.on(RoomEvent.TrackUnsubscribed, onAudioGone)
|
||||||
.on(RoomEvent.AudioPlaybackStatusChanged, onPlaybackChanged)
|
.on(RoomEvent.AudioPlaybackStatusChanged, onPlaybackChanged)
|
||||||
.on(RoomEvent.Disconnected, onDisconnected);
|
.on(RoomEvent.Disconnected, onDisconnected);
|
||||||
|
attachExistingAudio();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
room
|
room
|
||||||
@@ -162,6 +188,8 @@ export function PodView({
|
|||||||
.off(RoomEvent.TrackUnsubscribed, onAudioGone)
|
.off(RoomEvent.TrackUnsubscribed, onAudioGone)
|
||||||
.off(RoomEvent.AudioPlaybackStatusChanged, onPlaybackChanged)
|
.off(RoomEvent.AudioPlaybackStatusChanged, onPlaybackChanged)
|
||||||
.off(RoomEvent.Disconnected, onDisconnected);
|
.off(RoomEvent.Disconnected, onDisconnected);
|
||||||
|
audioElementsRef.current.forEach((el) => el.remove());
|
||||||
|
audioElementsRef.current.clear();
|
||||||
};
|
};
|
||||||
}, [room, me]);
|
}, [room, me]);
|
||||||
|
|
||||||
@@ -200,6 +228,22 @@ export function PodView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function playPodManVoiceTest() {
|
||||||
|
if (!room) return;
|
||||||
|
setNote(null);
|
||||||
|
setTestingVoice(true);
|
||||||
|
try {
|
||||||
|
await room.startAudio().catch(() => {});
|
||||||
|
setAudioBlocked(!room.canPlaybackAudio);
|
||||||
|
await testPodVoice(team.id);
|
||||||
|
setNote('PodMan voice test sent.');
|
||||||
|
} catch (e) {
|
||||||
|
setNote(`PodMan voice test failed: ${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
setTestingVoice(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleScreen() {
|
async function toggleScreen() {
|
||||||
primeSpeech(); // unlock browser voice from this gesture too
|
primeSpeech(); // unlock browser voice from this gesture too
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
@@ -317,6 +361,14 @@ export function PodView({
|
|||||||
<Volume2Icon data-icon="inline-start" />
|
<Volume2Icon data-icon="inline-start" />
|
||||||
{beat.on ? (beat.mine ? 'Stop audio' : `Stop (${beat.by})`) : 'Test audio'}
|
{beat.on ? (beat.mine ? 'Stop audio' : `Stop (${beat.by})`) : 'Test audio'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void playPodManVoiceTest()}
|
||||||
|
disabled={!room || testingVoice}
|
||||||
|
>
|
||||||
|
<RadioTowerIcon data-icon="inline-start" />
|
||||||
|
{testingVoice ? 'Sending voice' : 'Test PodMan voice'}
|
||||||
|
</Button>
|
||||||
<Button onClick={toggleScreen} disabled={!room}>
|
<Button onClick={toggleScreen} disabled={!room}>
|
||||||
<MonitorUpIcon data-icon="inline-start" />
|
<MonitorUpIcon data-icon="inline-start" />
|
||||||
{sharing ? 'Stop sharing' : 'Share screen'}
|
{sharing ? 'Stop sharing' : 'Share screen'}
|
||||||
@@ -516,6 +568,7 @@ export function PodView({
|
|||||||
</main>
|
</main>
|
||||||
<div
|
<div
|
||||||
ref={audioRef}
|
ref={audioRef}
|
||||||
|
data-testid="livekit-audio-sink"
|
||||||
className="pointer-events-none fixed size-px overflow-hidden opacity-0"
|
className="pointer-events-none fixed size-px overflow-hidden opacity-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -122,6 +122,17 @@ export async function addMember(id: string, name: string): Promise<Pod> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function testPodVoice(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/voice-test`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: 'PodMan voice test. Gemini TTS is playing through LiveKit.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`voice test failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function removeMember(id: string, name: string): Promise<Pod> {
|
export async function removeMember(id: string, name: string): Promise<Pod> {
|
||||||
return json(
|
return json(
|
||||||
await fetch(
|
await fetch(
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"deploy:static:local": "node scripts/deploy-static-local.mjs",
|
"deploy:static:local": "node scripts/deploy-static-local.mjs",
|
||||||
"hermes:watchdog": "node scripts/hermes-watchdog.mjs",
|
"hermes:watchdog": "node scripts/hermes-watchdog.mjs",
|
||||||
"hermes:watchdog:strict": "node scripts/hermes-watchdog.mjs --strict",
|
"hermes:watchdog:strict": "node scripts/hermes-watchdog.mjs --strict",
|
||||||
|
"hermes:notify": "node scripts/hermes-notify.mjs",
|
||||||
"hermes:sync-deploy": "node scripts/hermes-sync-deploy.mjs",
|
"hermes:sync-deploy": "node scripts/hermes-sync-deploy.mjs",
|
||||||
"hermes:install": "node scripts/install-hermes-ops.mjs",
|
"hermes:install": "node scripts/install-hermes-ops.mjs",
|
||||||
"healthcheck:public": "node scripts/healthcheck-public.mjs",
|
"healthcheck:public": "node scripts/healthcheck-public.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { config as loadEnv } from 'dotenv';
|
||||||
|
|
||||||
|
const envPath = process.env.DOTENV_CONFIG_PATH ?? (existsSync('.env') ? '.env' : 'backend/.env');
|
||||||
|
loadEnv({ path: envPath, quiet: true });
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
console.error(
|
||||||
|
'Usage: node scripts/hermes-notify.mjs --pod <podId> --message <text> [--engineers alice,bob] [--file path] [--urgent]',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function arg(name) {
|
||||||
|
const index = process.argv.indexOf(name);
|
||||||
|
return index === -1 ? '' : (process.argv[index + 1] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const podId = arg('--pod');
|
||||||
|
const message = arg('--message');
|
||||||
|
if (!podId || !message) usage();
|
||||||
|
|
||||||
|
const apiBase = (
|
||||||
|
process.env.PODMAN_API_URL ??
|
||||||
|
process.env.BACKEND_URL ??
|
||||||
|
`http://127.0.0.1:${process.env.PORT ?? '8787'}`
|
||||||
|
).replace(/\/$/, '');
|
||||||
|
|
||||||
|
const engineers = arg('--engineers')
|
||||||
|
.split(',')
|
||||||
|
.map((name) => name.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const file = arg('--file');
|
||||||
|
const urgent = process.argv.includes('--urgent');
|
||||||
|
|
||||||
|
const res = await globalThis.fetch(`${apiBase}/api/pods/${encodeURIComponent(podId)}/hermes/notify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
message,
|
||||||
|
...(engineers.length ? { engineers } : {}),
|
||||||
|
...(file ? { file } : {}),
|
||||||
|
...(urgent ? { urgency: 'urgent' } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(text);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(text);
|
||||||
@@ -95,6 +95,25 @@ async function verifyApi() {
|
|||||||
);
|
);
|
||||||
if (!withMember.members.includes('Hermes')) fail('member add did not persist');
|
if (!withMember.members.includes('Hermes')) fail('member add did not persist');
|
||||||
|
|
||||||
|
const hermesNotify = await json(
|
||||||
|
await doFetch(`${baseUrl}/api/pods/${encodeURIComponent(created.id)}/hermes/notify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: 'Hermes verification notification.',
|
||||||
|
engineers: ['Alice', 'Bob'],
|
||||||
|
file: 'src/verify-hermes.ts',
|
||||||
|
dryRun: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
hermesNotify.livekit !== 'dry-run' ||
|
||||||
|
hermesNotify.intervention?.message !== 'Hermes verification notification.'
|
||||||
|
) {
|
||||||
|
fail('Hermes notify endpoint returned unexpected payload');
|
||||||
|
}
|
||||||
|
|
||||||
await json(
|
await json(
|
||||||
await doFetch(`${baseUrl}/api/pods/${encodeURIComponent(created.id)}`, { method: 'DELETE' }),
|
await doFetch(`${baseUrl}/api/pods/${encodeURIComponent(created.id)}`, { method: 'DELETE' }),
|
||||||
);
|
);
|
||||||
@@ -262,6 +281,7 @@ try {
|
|||||||
'health',
|
'health',
|
||||||
'token',
|
'token',
|
||||||
'pod-crud',
|
'pod-crud',
|
||||||
|
'hermes-notify',
|
||||||
'collision',
|
'collision',
|
||||||
'memory-recall',
|
'memory-recall',
|
||||||
'graph',
|
'graph',
|
||||||
|
|||||||
@@ -21,7 +21,14 @@ const { DATA_TOPIC } = await import('../shared/dist/messages.js').catch(() => ({
|
|||||||
DATA_TOPIC: 'podman.intervention',
|
DATA_TOPIC: 'podman.intervention',
|
||||||
}));
|
}));
|
||||||
const backendRequire = createRequire(new URL('../backend/package.json', import.meta.url));
|
const backendRequire = createRequire(new URL('../backend/package.json', import.meta.url));
|
||||||
const { Room } = backendRequire('@livekit/rtc-node');
|
const {
|
||||||
|
AudioFrame,
|
||||||
|
AudioSource,
|
||||||
|
LocalAudioTrack,
|
||||||
|
Room,
|
||||||
|
TrackPublishOptions,
|
||||||
|
TrackSource,
|
||||||
|
} = backendRequire('@livekit/rtc-node');
|
||||||
const pods = await fetchJson('/api/pods');
|
const pods = await fetchJson('/api/pods');
|
||||||
const verifyPod = pods.find((pod) => pod.id === 'frontend-pod') ?? pods[0];
|
const verifyPod = pods.find((pod) => pod.id === 'frontend-pod') ?? pods[0];
|
||||||
if (!verifyPod) throw new Error('no pods available for frontend verification');
|
if (!verifyPod) throw new Error('no pods available for frontend verification');
|
||||||
@@ -123,6 +130,27 @@ async function publishDataMessage(room, message) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function publishAudioProbe(room) {
|
||||||
|
const source = new AudioSource(24_000, 1, 5_000);
|
||||||
|
const track = LocalAudioTrack.createAudioTrack(`verify-audio-${process.pid}`, source);
|
||||||
|
const options = new TrackPublishOptions();
|
||||||
|
options.source = TrackSource.SOURCE_MICROPHONE;
|
||||||
|
const publication = await room.localParticipant.publishTrack(track, options);
|
||||||
|
await source.captureFrame(new AudioFrame(new Int16Array(24_000), 24_000, 1, 24_000));
|
||||||
|
return { source, publication };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAttachedAudio(page) {
|
||||||
|
const audioSink = page.getByTestId('livekit-audio-sink');
|
||||||
|
await audioSink.waitFor({ timeout: 15_000 });
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const count = await audioSink.locator('audio').count();
|
||||||
|
if (count > 0) return;
|
||||||
|
await delay(250);
|
||||||
|
}
|
||||||
|
throw new Error('LiveKit audio track was not attached to the hidden audio sink');
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForInterventionCard(page, room, podId) {
|
async function waitForInterventionCard(page, room, podId) {
|
||||||
const cardText = 'Verification collision: two engineers are editing frontend/src/App.tsx.';
|
const cardText = 'Verification collision: two engineers are editing frontend/src/App.tsx.';
|
||||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
@@ -284,8 +312,8 @@ try {
|
|||||||
const podCard = page
|
const podCard = page
|
||||||
.getByText(verifyPod.name, { exact: true })
|
.getByText(verifyPod.name, { exact: true })
|
||||||
.locator('xpath=ancestor::*[.//input[@placeholder="Your name"]][1]');
|
.locator('xpath=ancestor::*[.//input[@placeholder="Your name"]][1]');
|
||||||
await podCard.getByPlaceholder('Your name').fill(verifyMember);
|
await podCard.getByPlaceholder('Your name').first().fill(verifyMember);
|
||||||
await podCard.getByRole('button', { name: 'Add and join' }).click();
|
await podCard.getByRole('button', { name: 'Join' }).first().click();
|
||||||
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
|
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
|
||||||
if (new URL(page.url()).pathname !== `/${verifyPod.id}`) {
|
if (new URL(page.url()).pathname !== `/${verifyPod.id}`) {
|
||||||
throw new Error(`join did not update URL to /${verifyPod.id}: ${page.url()}`);
|
throw new Error(`join did not update URL to /${verifyPod.id}: ${page.url()}`);
|
||||||
@@ -384,6 +412,18 @@ try {
|
|||||||
|
|
||||||
const publisher = await connectPublisher(verifyPod.id);
|
const publisher = await connectPublisher(verifyPod.id);
|
||||||
try {
|
try {
|
||||||
|
const audioProbe = await publishAudioProbe(publisher);
|
||||||
|
try {
|
||||||
|
await waitForAttachedAudio(page);
|
||||||
|
} finally {
|
||||||
|
if (audioProbe.publication.sid) {
|
||||||
|
await publisher.localParticipant
|
||||||
|
.unpublishTrack(audioProbe.publication.sid, true)
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
await audioProbe.source.close().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
const intervention = await waitForInterventionCard(page, publisher, verifyPod.id);
|
const intervention = await waitForInterventionCard(page, publisher, verifyPod.id);
|
||||||
await publishDataMessage(publisher, {
|
await publishDataMessage(publisher, {
|
||||||
type: 'HERMES_MESSAGE',
|
type: 'HERMES_MESSAGE',
|
||||||
@@ -432,6 +472,7 @@ try {
|
|||||||
graph: true,
|
graph: true,
|
||||||
joined: true,
|
joined: true,
|
||||||
screenShare: 'livekit-published',
|
screenShare: 'livekit-published',
|
||||||
|
audioSink: 'livekit-attached',
|
||||||
intervention: 'collision-hermes-voice',
|
intervention: 'collision-hermes-voice',
|
||||||
podId: verifyPod.id,
|
podId: verifyPod.id,
|
||||||
member: verifyMember,
|
member: verifyMember,
|
||||||
|
|||||||
Reference in New Issue
Block a user