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:
Yahya Alhinai
2026-06-28 06:45:37 +00:00
parent 7269098ea3
commit c1ac687dcf
12 changed files with 438 additions and 31 deletions
+54
View File
@@ -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);
+20
View File
@@ -95,6 +95,25 @@ async function verifyApi() {
);
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 doFetch(`${baseUrl}/api/pods/${encodeURIComponent(created.id)}`, { method: 'DELETE' }),
);
@@ -262,6 +281,7 @@ try {
'health',
'token',
'pod-crud',
'hermes-notify',
'collision',
'memory-recall',
'graph',
+44 -3
View File
@@ -21,7 +21,14 @@ const { DATA_TOPIC } = await import('../shared/dist/messages.js').catch(() => ({
DATA_TOPIC: 'podman.intervention',
}));
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 verifyPod = pods.find((pod) => pod.id === 'frontend-pod') ?? pods[0];
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) {
const cardText = 'Verification collision: two engineers are editing frontend/src/App.tsx.';
for (let attempt = 1; attempt <= 3; attempt++) {
@@ -284,8 +312,8 @@ try {
const podCard = page
.getByText(verifyPod.name, { exact: true })
.locator('xpath=ancestor::*[.//input[@placeholder="Your name"]][1]');
await podCard.getByPlaceholder('Your name').fill(verifyMember);
await podCard.getByRole('button', { name: 'Add and join' }).click();
await podCard.getByPlaceholder('Your name').first().fill(verifyMember);
await podCard.getByRole('button', { name: 'Join' }).first().click();
await page.getByRole('button', { name: 'Share screen' }).waitFor({ timeout: 15_000 });
if (new URL(page.url()).pathname !== `/${verifyPod.id}`) {
throw new Error(`join did not update URL to /${verifyPod.id}: ${page.url()}`);
@@ -384,6 +412,18 @@ try {
const publisher = await connectPublisher(verifyPod.id);
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);
await publishDataMessage(publisher, {
type: 'HERMES_MESSAGE',
@@ -432,6 +472,7 @@ try {
graph: true,
joined: true,
screenShare: 'livekit-published',
audioSink: 'livekit-attached',
intervention: 'collision-hermes-voice',
podId: verifyPod.id,
member: verifyMember,