Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1901010996 | |||
| d9776452b2 | |||
| 0d69f82139 | |||
| 1d097b13af | |||
| ab8ea07c12 |
+47
-10
@@ -9,11 +9,42 @@ import {
|
||||
recordIntervention,
|
||||
updateInterventionStatus,
|
||||
} from '../memory/store.js';
|
||||
import { getGitStates } from '../memory/db.js';
|
||||
import { getGitStates, type GitState } from '../memory/db.js';
|
||||
import { recallSimilar } from '../memory/vectors.js';
|
||||
import { shouldIntervene, preferredAction } from '../memory/policy.js';
|
||||
import { publishHermesIntervention } from '../action/hermes.js';
|
||||
|
||||
/** Strip a git-status prefix ("M ", "?? ") and reduce a path to its lowercased
|
||||
* basename — matches comparableFile() in memory/store.ts so keys line up. */
|
||||
function comparableBasename(raw?: string): string {
|
||||
return (
|
||||
(raw ?? '')
|
||||
.trim()
|
||||
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.toLowerCase() ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
/** Canonicalize an engineer name for case/whitespace-insensitive matching, so
|
||||
* "Karti" and "karti" resolve to the same engineer's git state. */
|
||||
function canonicalName(raw?: string): string {
|
||||
return (raw ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Git ground truth: do ALL involved engineers currently have the collided file
|
||||
* in their changedFiles? Computed at detection time while git state is fresh. */
|
||||
function engineersOverlapOnFile(collision: Collision, gitStates: Map<string, GitState>): boolean {
|
||||
const target = comparableBasename(collision.file);
|
||||
if (!target || collision.engineers.length < 2) return false;
|
||||
const byCanon = new Map<string, string[]>();
|
||||
for (const [name, st] of gitStates) byCanon.set(canonicalName(name), st.changedFiles);
|
||||
return collision.engineers.every((e) =>
|
||||
(byCanon.get(canonicalName(e)) ?? []).some((f) => comparableBasename(f) === target),
|
||||
);
|
||||
}
|
||||
|
||||
export class PodMan {
|
||||
private contexts = new Map<string, EngineerContext>();
|
||||
/**
|
||||
@@ -75,6 +106,12 @@ export class PodMan {
|
||||
if (!current.has(key)) this.activeConflicts.delete(key);
|
||||
}
|
||||
|
||||
// Capture git ground-truth overlap now, while engineer_states are fresh, so
|
||||
// the outcome-time verifier never depends on a stale sidecar or a late click.
|
||||
for (const collision of collisions) {
|
||||
collision.gitOverlap = engineersOverlapOnFile(collision, gitStates);
|
||||
}
|
||||
|
||||
for (const collision of collisions) await this.handle(collision);
|
||||
}
|
||||
|
||||
@@ -85,14 +122,7 @@ export class PodMan {
|
||||
* basename.
|
||||
*/
|
||||
private conflictKey(collision: Collision): string {
|
||||
return (
|
||||
(collision.file ?? '')
|
||||
.trim()
|
||||
.replace(/^(\?\?|[MADRCU!]{1,2})\s+/, '')
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.toLowerCase() ?? ''
|
||||
);
|
||||
return comparableBasename(collision.file);
|
||||
}
|
||||
|
||||
private async handle(collision: Collision): Promise<void> {
|
||||
@@ -100,7 +130,14 @@ export class PodMan {
|
||||
if (this.activeConflicts.has(key)) return; // single-shot: already voiced, still unresolved
|
||||
|
||||
const prior = await recallSimilar(collision); // Loop A: exact/vector recall raises confidence
|
||||
if (prior) collision.severity = 'critical';
|
||||
// Only escalate to critical (which triggers the spoken alert) when the
|
||||
// recalled prior was an *accepted real* collision. Blanket-escalating every
|
||||
// recall — including dismissed/false-positive priors — masked the learned
|
||||
// routing in preferredAction and made recalled noise scream "CRITICAL".
|
||||
// (RSI Step 2 — continual-learning/policy.md:62-63, plan.md:66)
|
||||
if (prior?.priorOutcome?.accepted && prior?.priorOutcome?.wasRealCollision) {
|
||||
collision.severity = 'critical';
|
||||
}
|
||||
if (!shouldIntervene(collision, prior)) return; // Loop B: policy gate
|
||||
|
||||
this.activeConflicts.add(key); // claim now we're alerting; re-armed in onScreenFrame on resolution
|
||||
|
||||
@@ -12,7 +12,12 @@ export function shouldIntervene(collision: Collision, prior: RecalledCollision |
|
||||
if (collision.severity === 'info') return false;
|
||||
|
||||
const priorOutcome = prior?.priorOutcome;
|
||||
if (priorOutcome && !priorOutcome.accepted && !priorOutcome.wasRealCollision) return false;
|
||||
// Suppress when the identical prior was dismissed (accepted === false). The
|
||||
// former `&& !priorOutcome.wasRealCollision` term was dead code: outcomes are
|
||||
// recorded with wasRealCollision hardcoded true, so the gate never fired and
|
||||
// the 85 real dismissals in Atlas were ignored. Dismissals are the negative
|
||||
// signal per continual-learning/policy.md:41 + spec.md:163. (RSI Step 1)
|
||||
if (priorOutcome && !priorOutcome.accepted) return false;
|
||||
|
||||
const cooldown = cooldownMs();
|
||||
const last = lastNudgeByPod.get(collision.podId) ?? 0;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
InterventionOutcome,
|
||||
InterventionStatus,
|
||||
} from '@podman/shared';
|
||||
import { collections } from './db.js';
|
||||
import { collections, getGitStates } from './db.js';
|
||||
import { enrichCollisionMemory } from './vectors.js';
|
||||
|
||||
function comparableFile(raw?: string): string {
|
||||
@@ -84,13 +84,53 @@ export async function updateInterventionStatus(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 — derive whether a flagged collision was REAL from git ground truth,
|
||||
* instead of trusting the client (which historically hardcoded `true`). A
|
||||
* collision counts as real only if BOTH named engineers currently have the
|
||||
* collided file in their git `changedFiles`. Conservative: returns false when
|
||||
* the collision is orphaned/missing or git state is stale/unavailable.
|
||||
* Verifier supervision per docs/continual-learning/spec.md:98-108, policy.md:35-42.
|
||||
*/
|
||||
export async function deriveWasRealCollision(outcome: InterventionOutcome): Promise<boolean> {
|
||||
try {
|
||||
const c = await collections();
|
||||
const collision = await c.collisions.findOne({ id: outcome.collisionId });
|
||||
if (!collision) return false;
|
||||
// Prefer the overlap evidence captured at detection time (fresh git state):
|
||||
// immune to late clicks, stale sidecars, and the engineer_states TTL.
|
||||
if (typeof collision.gitOverlap === 'boolean') return collision.gitOverlap;
|
||||
// Fallback for collisions detected before gitOverlap was captured: re-derive
|
||||
// from latest git state, matching engineers on case/whitespace-canonical names.
|
||||
if (!Array.isArray(collision.engineers) || collision.engineers.length < 2) return false;
|
||||
const target = comparableFile(collision.file);
|
||||
if (!target) return false;
|
||||
const byCanon = new Map<string, string[]>();
|
||||
for (const [name, st] of await getGitStates(outcome.podId)) {
|
||||
byCanon.set(name.trim().toLowerCase(), st.changedFiles);
|
||||
}
|
||||
return collision.engineers.every((e) =>
|
||||
(byCanon.get(e.trim().toLowerCase()) ?? []).some((f) => comparableFile(f) === target),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`[memory] wasRealCollision verifier failed: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordOutcome(outcome: InterventionOutcome): Promise<void> {
|
||||
// Backend is authoritative for wasRealCollision: derive it from git overlap
|
||||
// rather than trusting the client-supplied value. (RSI Step 3)
|
||||
const verified: InterventionOutcome = {
|
||||
...outcome,
|
||||
wasRealCollision: await deriveWasRealCollision(outcome),
|
||||
};
|
||||
await persist('outcome', async () => {
|
||||
const c = await collections();
|
||||
await c.outcomes.insertOne({ ...outcome });
|
||||
await c.outcomes.insertOne({ ...verified });
|
||||
await c.interventions.updateOne(
|
||||
{ id: outcome.interventionId },
|
||||
{ $set: { status: outcome.accepted ? 'accepted' : 'dismissed' } },
|
||||
{ id: verified.interventionId },
|
||||
{ $set: { status: verified.accepted ? 'accepted' : 'dismissed' } },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { loadPodGraph, reachFrom } from './graph/store.js';
|
||||
import { listPodActivity } from './activity/store.js';
|
||||
import { getMemberWorkHistory } from './activity/member-history.js';
|
||||
import { speakInRoom } from './voice/live.js';
|
||||
import { getPodMusic } from './voice/music.js';
|
||||
import { notifyHermesInterventionInRoom } from './action/hermes.js';
|
||||
import {
|
||||
activeLiveConversation,
|
||||
@@ -406,6 +407,21 @@ app.get('/api/internal/hermes/jobs/:jobId/events/stream', async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Per-pod background music (Lyria), generated once and cached. Streams MP3 the
|
||||
// frontend loops as a pod-wide LiveKit track (replaces the synthesized beat).
|
||||
app.get('/api/pods/:id/music', async (req, res) => {
|
||||
try {
|
||||
const pod = await getPod(req.params.id);
|
||||
if (!pod) return res.status(404).json({ error: 'pod not found' });
|
||||
const mp3 = await getPodMusic(pod.id, pod.name);
|
||||
res.set('Content-Type', 'audio/mpeg');
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.send(mp3);
|
||||
} 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);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { getDb } from '../memory/db.js';
|
||||
import { env } from '../env.js';
|
||||
|
||||
// Lyria 3 is reached via the Gemini "interactions" endpoint (not :predict, which
|
||||
// is the Vertex path). The clip model returns a ~30s base64 MP3.
|
||||
const MUSIC_MODEL = process.env.GEMINI_MUSIC_MODEL ?? 'lyria-3-clip-preview';
|
||||
const INTERACTIONS_URL = 'https://generativelanguage.googleapis.com/v1beta/interactions';
|
||||
|
||||
interface PodMusicDoc {
|
||||
podId: string;
|
||||
name: string; // pod name the vocal was generated for
|
||||
model: string;
|
||||
mp3Base64: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface InteractionContent {
|
||||
type?: string;
|
||||
data?: string;
|
||||
text?: string;
|
||||
}
|
||||
interface InteractionResponse {
|
||||
steps?: Array<{ content?: InteractionContent[] }>;
|
||||
output_audio?: { data?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Background "hold music" prompt: opens with the pod name sung once, then a calm
|
||||
* instrumental bed that loops. Keep it unobtrusive — this is fill, not a song.
|
||||
*/
|
||||
function musicPrompt(podName: string): string {
|
||||
return [
|
||||
'Calm soothing instrumental background hold music for a tech app, like gentle on-hold lobby music.',
|
||||
`It opens in the first three seconds with a soft gentle voice clearly saying the words "${podName}" one time,`,
|
||||
'and after that opening it is purely instrumental with warm electric piano, gentle synth pads and a soft relaxed beat.',
|
||||
'Unobtrusive, pleasant and steady with no climax, designed to loop seamlessly as quiet background fill.',
|
||||
'No other lyrics or vocals after the opening.',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function extractAudioBase64(data: InteractionResponse): string | null {
|
||||
for (const step of data.steps ?? []) {
|
||||
for (const c of step.content ?? []) {
|
||||
if (c.type === 'audio' && c.data) return c.data;
|
||||
}
|
||||
}
|
||||
return data.output_audio?.data ?? null;
|
||||
}
|
||||
|
||||
async function generate(podName: string): Promise<Buffer> {
|
||||
const res = await fetch(INTERACTIONS_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': env.GEMINI_API_KEY },
|
||||
body: JSON.stringify({ model: MUSIC_MODEL, input: musicPrompt(podName) }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Lyria ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
||||
}
|
||||
const data = (await res.json()) as InteractionResponse;
|
||||
const b64 = extractAudioBase64(data);
|
||||
if (!b64) throw new Error('Lyria returned no audio');
|
||||
return Buffer.from(b64, 'base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* The pod's background-music MP3, generated by Lyria on first request and cached
|
||||
* in the `pod_music` collection. Regenerated if the pod name changes so the sung
|
||||
* name stays correct. Lyria generation is slow (~20s); the cache makes every
|
||||
* call after the first instant.
|
||||
*/
|
||||
export async function getPodMusic(podId: string, podName: string): Promise<Buffer> {
|
||||
const db = await getDb();
|
||||
const col = db.collection<PodMusicDoc>('pod_music');
|
||||
const cached = await col.findOne({ podId });
|
||||
if (cached && cached.name === podName && cached.model === MUSIC_MODEL && cached.mp3Base64) {
|
||||
return Buffer.from(cached.mp3Base64, 'base64');
|
||||
}
|
||||
const mp3 = await generate(podName);
|
||||
await col.updateOne(
|
||||
{ podId },
|
||||
{
|
||||
$set: {
|
||||
podId,
|
||||
name: podName,
|
||||
model: MUSIC_MODEL,
|
||||
mp3Base64: mp3.toString('base64'),
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
return mp3;
|
||||
}
|
||||
@@ -484,6 +484,52 @@ artifact.
|
||||
recorded backup.
|
||||
- Keep backup video on a separate device.
|
||||
|
||||
### P0.5 - RSI negative-feedback activation (continual-learning)
|
||||
|
||||
The continual-learning loop records outcomes but never feeds the negative
|
||||
signal back. Live Atlas (2026-06-28): `outcomes` = 22 accepted / 85 dismissed,
|
||||
yet `wasRealCollision` is `true` in 107/107 (hardcoded), so the suppression
|
||||
gate is dead and dismissals are unused. These two rungs activate the loop with
|
||||
no schema change. Owner: RSI track. Independent of the MongoDB-cleanup handoff.
|
||||
|
||||
1. **Step 1 - suppress on prior dismissal alone** ✅
|
||||
- `backend/src/memory/policy.ts` `shouldIntervene`: remove the dead
|
||||
`&& !priorOutcome.wasRealCollision` term so a prior `accepted === false`
|
||||
suppresses the next identical-signature nudge.
|
||||
- Spec: `docs/continual-learning/policy.md:41` (dismissed = negative signal),
|
||||
`spec.md:163` (dismissals adapt suppression).
|
||||
- Caveat: recall is single-shot most-recent (`memory/vectors.ts`), so this is
|
||||
"last-outcome-wins" until Step 3 (derive `wasRealCollision`) lands.
|
||||
|
||||
2. **Step 2 - gate the recall severity escalation** ✅
|
||||
- `backend/src/agent/podman.ts` `handle`: only force `severity = 'critical'`
|
||||
when the recalled prior was an accepted *real* collision, instead of
|
||||
blanket-escalating every recall. Surfaces the learned routing in
|
||||
`preferredAction`; stops dismissed/false priors over-escalating to voice.
|
||||
- Spec: `docs/continual-learning/policy.md:62-63` (prefer prior accepted
|
||||
kind), `plan.md:66` (second similar event behaves differently).
|
||||
|
||||
3. **Step 3 - derive `wasRealCollision` from git overlap (backend-authoritative)** ✅
|
||||
- Overlap is captured AT detection time as `Collision.gitOverlap`
|
||||
(`backend/src/agent/podman.ts`), while `engineer_states` are still fresh —
|
||||
true only if ALL involved engineers have the collided file in their git
|
||||
`changedFiles`, matched on case/whitespace-canonical names.
|
||||
- `backend/src/memory/store.ts` `recordOutcome` overrides the client value
|
||||
with `deriveWasRealCollision()`, which prefers the stored `gitOverlap`
|
||||
(immune to late clicks / stale sidecars / the 120s TTL) and only falls back
|
||||
to a live canonical-name re-derivation for pre-existing collisions.
|
||||
`frontend/.../useInterventions.ts` stops sending hardcoded `true`.
|
||||
- Restores the (accepted × wasReal) 2×2 the spec assumes; keeps `learned_from`
|
||||
edges (`graph/live.ts:413`) from being silently zeroed on stage.
|
||||
- Spec: `docs/continual-learning/spec.md:98-108`, `policy.md:35-42`.
|
||||
- Hardened per Codex review (name canonicalization + detection-time capture).
|
||||
|
||||
Follow-ups (separate rungs, not in this change): Step 4-5 `strategy_versions` +
|
||||
Gemini-proposed `LearningProposal` slice; Step 6 durable `owns` write; seed a
|
||||
clean demo pod with a repeated dismissed signature (the historic dismissals are
|
||||
orphaned — `collisionId` resolves to no collision — so they cannot drive the
|
||||
demo verifier).
|
||||
|
||||
### P1 - polish the money moment
|
||||
|
||||
- Add visible live inference captions in the PWA.
|
||||
|
||||
+52
-37
@@ -1,64 +1,79 @@
|
||||
# Shared Test Audio — pod-wide connectivity check
|
||||
# Shared Background Music — pod-wide audio + connectivity check
|
||||
|
||||
> Spec for the `frontend/src/livekit/useBeat.ts` + `PodView.tsx` test-audio
|
||||
> behavior and the additive `BEAT_STOP` data message. Satisfies the
|
||||
> Spec for the `frontend/src/livekit/useBeat.ts` + `PodView.tsx` background-music
|
||||
> behavior, the `lib/beat.ts` audio source, the `GET /api/pods/:id/music`
|
||||
> endpoint, and the additive `BEAT_STOP` data message. Satisfies the
|
||||
> documentation-first gate for those files.
|
||||
|
||||
## Why
|
||||
|
||||
The **Test audio** button is PodMan's pre-flight check that the LiveKit audio
|
||||
path works for the whole pod — the same path the urgent Gemini-TTS voice
|
||||
escalation rides on. Today the beat is published correctly but its on/off state
|
||||
is **local to the publisher**: teammates can't see it's playing and can't stop
|
||||
it. This makes it a shared, pod-wide toggle so a judge sees the state flip on
|
||||
every screen at once.
|
||||
The **Background Music** button is PodMan's pod-wide audio: a calm, looping
|
||||
background track unique to each pod, generated by Gemini **Lyria 3**. It opens
|
||||
with the pod's name sung once, then settles into a soft instrumental bed. It also
|
||||
doubles as the pre-flight check that the LiveKit audio path works for the whole
|
||||
pod — the same path the urgent Gemini-TTS voice escalation rides on. Its on/off
|
||||
state is shared pod-wide so a judge sees it flip on every screen at once.
|
||||
|
||||
## Behavior
|
||||
## Music generation (backend)
|
||||
|
||||
- Any participant clicks **Test audio** → they publish the `podman-beat` audio
|
||||
track (Web Audio, `lib/beat.ts`). Everyone auto-subscribes and hears it.
|
||||
- `GET /api/pods/:id/music` → streams the pod's background-music MP3
|
||||
(`audio/mpeg`). On first request it calls Lyria 3 (`lyria-3-clip-preview`) via
|
||||
the Gemini **interactions** endpoint with a prompt that sings the pod name in
|
||||
the first ~3s then stays instrumental, and **caches** the MP3 in the
|
||||
`pod_music` Mongo collection (keyed by pod id; regenerated if the pod name
|
||||
changes). Subsequent requests are instant. The Gemini key stays server-side.
|
||||
- `backend/src/voice/music.ts` owns generation + caching (`getPodMusic`).
|
||||
- Override the model with `GEMINI_MUSIC_MODEL` (default `lyria-3-clip-preview`).
|
||||
|
||||
## Behavior (frontend)
|
||||
|
||||
- Any participant clicks **Background Music** → the client fetches the pod's MP3
|
||||
and loops it (`lib/beat.ts` `startMusic`, Web Audio `AudioBufferSource.loop`),
|
||||
publishing it as the `podman-beat` track. Everyone auto-subscribes and hears
|
||||
it; the publisher hears it locally too.
|
||||
- The shared on/off state is **derived from the track's presence**, not a synced
|
||||
flag — so it self-syncs across joins/leaves and can't drift from reality. The
|
||||
publisher is the **owner**.
|
||||
flag — so it self-syncs across joins/leaves and can't drift. The publisher is
|
||||
the **owner**.
|
||||
- Anyone can stop it:
|
||||
- Owner clicks **Stop audio** → unpublishes its own track directly.
|
||||
- Non-owner clicks **Stop (`<owner>`'s)** → sends `BEAT_STOP`; the owner
|
||||
unpublishes. (LiveKit forbids unpublishing another participant's track, so a
|
||||
request is the only way.)
|
||||
- The Status card shows `publishing` / `<owner> playing` / `ready`, and the
|
||||
waveform animates (`active`) for everyone while the test is live.
|
||||
- Owner clicks **Stop music** → unpublishes its own track directly.
|
||||
- Non-owner clicks **Stop (`<owner>`)** → sends `BEAT_STOP`; the owner
|
||||
unpublishes. (LiveKit forbids unpublishing another participant's track.)
|
||||
- `PodView` warms the cache with a fire-and-forget fetch on mount so the first
|
||||
click plays instantly.
|
||||
|
||||
## State derivation (source of truth = the track)
|
||||
|
||||
`useBeat(room)` returns `{ on, by, mine }`, recomputed from the presence of a
|
||||
track named `podman-beat` across `localParticipant` + `remoteParticipants` on
|
||||
these events: `LocalTrackPublished/Unpublished`, `TrackPublished/Unpublished`,
|
||||
`useBeat(room, musicUrl)` returns `{ beat, toggleBeat }` where `beat` is
|
||||
`{ on, by, mine }`, recomputed from the presence of a track named `podman-beat`
|
||||
across `localParticipant` + `remoteParticipants` on these events:
|
||||
`LocalTrackPublished/Unpublished`, `TrackPublished/Unpublished`,
|
||||
`TrackSubscribed/Unsubscribed`, `ParticipantConnected/Disconnected`. Owner
|
||||
disconnect and late-join sync therefore need no extra messaging.
|
||||
|
||||
## Contract (additive)
|
||||
|
||||
`shared/src/messages.ts` — one new message on the existing `podman.intervention`
|
||||
data topic:
|
||||
|
||||
| { type: 'BEAT_STOP' } // any participant → owner: stop the shared beat
|
||||
|
||||
Additive to the `DataMessage` union; existing consumers ignore unknown types.
|
||||
**No backend / API change.**
|
||||
`shared/src/messages.ts` — `{ type: 'BEAT_STOP' }` on the existing
|
||||
`podman.intervention` data topic (any participant → owner: stop the shared
|
||||
track). Additive to the `DataMessage` union; existing consumers ignore unknown
|
||||
types.
|
||||
|
||||
## Known limitation (LiveKit constraint)
|
||||
|
||||
A client can only unpublish **its own** tracks, so a non-owner's **Stop** is a
|
||||
`BEAT_STOP` _request_ the owner must honor. If the owner disconnects **uncleanly**
|
||||
(crash / network drop), the SFU keeps the track published until it times the
|
||||
participant out — during that window the beat keeps playing and non-owners can't
|
||||
stop it. A clean disconnect clears it immediately via `ParticipantDisconnected`.
|
||||
Demo mitigation: have the same person who starts the test also stop it.
|
||||
participant out — during that window the music keeps playing and non-owners
|
||||
can't stop it. A clean disconnect clears it immediately via
|
||||
`ParticipantDisconnected`. Demo mitigation: have the same person who starts it
|
||||
also stop it.
|
||||
|
||||
## Files
|
||||
|
||||
- `backend/src/voice/music.ts` — Lyria generation + `pod_music` cache.
|
||||
- `backend/src/server.ts` — `GET /api/pods/:id/music` (streams MP3).
|
||||
- `frontend/src/lib/api.ts` — `podMusicUrl(id)` helper.
|
||||
- `frontend/src/lib/beat.ts` — `startMusic(url)` (loops the MP3); legacy
|
||||
`startBeat()` (synthesized kick/hat) kept as a fallback.
|
||||
- `frontend/src/livekit/useBeat.ts` — `useBeat(room, musicUrl)` hook.
|
||||
- `frontend/src/components/PodView.tsx` — button label + cache warm-up.
|
||||
- `shared/src/messages.ts` — `BEAT_STOP` message (additive).
|
||||
- `frontend/src/livekit/useBeat.ts` — `useBeat(room)` hook.
|
||||
- `frontend/src/components/PodView.tsx` — button label, status line, waveform
|
||||
`active` driven by the hook.
|
||||
- `frontend/src/lib/beat.ts` — unchanged (existing Web-Audio beat source).
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
abortLiveConversationHermesJob,
|
||||
getLiveConversationHermesJob,
|
||||
getMemberWorkHistory,
|
||||
podMusicUrl,
|
||||
startLiveConversation,
|
||||
stopLiveConversation,
|
||||
testPodVoice,
|
||||
@@ -167,7 +168,7 @@ export function PodView({
|
||||
readStoredBool('podman.teamStreamOpen', true),
|
||||
);
|
||||
const { active, hermes, voiceCue, actionUrl, respond } = useInterventions(room);
|
||||
const { beat, toggleBeat: runBeat } = useBeat(room);
|
||||
const { beat, toggleBeat: runBeat } = useBeat(room, podMusicUrl(team.id));
|
||||
const activity = usePodActivity(team.id, me);
|
||||
|
||||
const audioRef = useRef<HTMLDivElement>(null);
|
||||
@@ -177,6 +178,11 @@ export function PodView({
|
||||
const onLeaveRef = useRef(onLeave);
|
||||
onLeaveRef.current = onLeave;
|
||||
|
||||
// Warm the pod's background-music cache so the first click plays instantly.
|
||||
useEffect(() => {
|
||||
void fetch(podMusicUrl(team.id)).catch(() => {});
|
||||
}, [team.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return;
|
||||
const refresh = () => setParticipants(snapshot(room, me));
|
||||
@@ -659,7 +665,7 @@ export function PodView({
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onToggleBeat} disabled={!room}>
|
||||
<Volume2Icon data-icon="inline-start" />
|
||||
{beat.on ? (beat.mine ? 'Stop audio' : `Stop (${beat.by})`) : 'Test audio'}
|
||||
{beat.on ? (beat.mine ? 'Stop music' : `Stop (${beat.by})`) : 'Background Music'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -106,6 +106,11 @@ export function podActivityStreamUrl(id: string): string {
|
||||
return `${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/activity/stream`;
|
||||
}
|
||||
|
||||
/** URL of the pod's generated background-music MP3 (looped client-side). */
|
||||
export function podMusicUrl(id: string): string {
|
||||
return `${BACKEND_URL}/api/pods/${encodeURIComponent(id)}/music`;
|
||||
}
|
||||
|
||||
export async function getMemberWorkHistory(
|
||||
podId: string,
|
||||
member: string,
|
||||
|
||||
@@ -75,3 +75,42 @@ export function startBeat(): BeatHandle {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an MP3 from `url` and loop it as an audio MediaStreamTrack to publish into
|
||||
* a LiveKit room (and play on local speakers). Used for pod background music
|
||||
* (Lyria-generated). Pure Web Audio — no asset bundling.
|
||||
*/
|
||||
export async function startMusic(url: string): Promise<BeatHandle> {
|
||||
const ctx = new AudioContext();
|
||||
await ctx.resume();
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`music fetch failed: ${res.status}`);
|
||||
const buffer = await ctx.decodeAudioData(await res.arrayBuffer());
|
||||
|
||||
const dest = ctx.createMediaStreamDestination();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.6;
|
||||
master.connect(dest); // -> published track (remote listeners)
|
||||
master.connect(ctx.destination); // -> local speakers (publisher)
|
||||
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buffer;
|
||||
src.loop = true;
|
||||
src.connect(master);
|
||||
src.start();
|
||||
|
||||
const track = dest.stream.getAudioTracks()[0]!;
|
||||
return {
|
||||
track,
|
||||
stop: () => {
|
||||
try {
|
||||
src.stop();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
track.stop();
|
||||
void ctx.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { RoomEvent, type Room } from 'livekit-client';
|
||||
import { DATA_TOPIC, type DataMessage } from '@podman/shared';
|
||||
import { startBeat, type BeatHandle } from '../lib/beat.js';
|
||||
import { startBeat, startMusic, type BeatHandle } from '../lib/beat.js';
|
||||
|
||||
/** Name of the test-audio track; its presence in the room IS the shared state. */
|
||||
export const BEAT_TRACK = 'podman-beat';
|
||||
@@ -23,7 +23,7 @@ const OFF: BeatState = { on: false, by: null, mine: false };
|
||||
* from the track's presence (self-syncing across joins/leaves). Any participant
|
||||
* can stop it: non-owners send BEAT_STOP and the owner unpublishes.
|
||||
*/
|
||||
export function useBeat(room: Room | null) {
|
||||
export function useBeat(room: Room | null, musicUrl?: string) {
|
||||
const [beat, setBeat] = useState<BeatState>(OFF);
|
||||
const beatRef = useRef<BeatHandle | null>(null);
|
||||
|
||||
@@ -133,7 +133,7 @@ export function useBeat(room: Room | null) {
|
||||
try {
|
||||
await room.startAudio().catch(() => {}); // unlock playback from this gesture
|
||||
if (unmountedRef.current) return;
|
||||
const handle = startBeat();
|
||||
const handle = musicUrl ? await startMusic(musicUrl) : startBeat();
|
||||
beatRef.current = handle;
|
||||
await room.localParticipant.publishTrack(handle.track, { name: BEAT_TRACK });
|
||||
if (unmountedRef.current) await stopLocal(); // left mid-publish — clean up
|
||||
@@ -144,7 +144,7 @@ export function useBeat(room: Room | null) {
|
||||
} finally {
|
||||
startingRef.current = false;
|
||||
}
|
||||
}, [room, beat, stopLocal]);
|
||||
}, [room, beat, stopLocal, musicUrl]);
|
||||
|
||||
return { beat, toggleBeat };
|
||||
}
|
||||
|
||||
@@ -66,7 +66,9 @@ export function useInterventions(room: Room | null) {
|
||||
interventionId: active.id,
|
||||
collisionId: active.collisionId,
|
||||
podId: active.podId,
|
||||
wasRealCollision: true,
|
||||
// Placeholder only — the backend derives the authoritative value from
|
||||
// git overlap at outcome time (the client cannot know). (RSI Step 3)
|
||||
wasRealCollision: false,
|
||||
accepted,
|
||||
recordedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -16,6 +16,14 @@ export interface Collision {
|
||||
severity: CollisionSeverity;
|
||||
/** Snapshot of relevant GitHub state at detection time. */
|
||||
githubState?: GithubStateSnapshot;
|
||||
/**
|
||||
* Git ground-truth overlap captured AT detection time, while engineer_states
|
||||
* are still fresh: true when every involved engineer had `file` in their git
|
||||
* changedFiles. Read as the authoritative wasRealCollision evidence at outcome
|
||||
* time, so a late click, a stale sidecar, or the engineer_states freshness TTL
|
||||
* cannot retroactively zero it out.
|
||||
*/
|
||||
gitOverlap?: boolean;
|
||||
detectedAt: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user