diff --git a/apps/api/src/search.ts b/apps/api/src/search.ts index e37b858..7e615c6 100644 --- a/apps/api/src/search.ts +++ b/apps/api/src/search.ts @@ -1,6 +1,7 @@ import type { Actor, MessageBlock, SearchHit } from "@rakazo/contracts"; import { extractLinksFromText, matchesSearchQuery, snippetAroundMatch } from "@rakazo/core"; -import { Prisma, type PrismaClient } from "@rakazo/db"; +import type { PrismaClient } from "@rakazo/db"; +import { Prisma } from "@rakazo/db"; const SEARCH_LIMIT = 25; /** Cap name matches so content hits (messages/files/links/routines) keep most of the budget. */ @@ -64,19 +65,65 @@ export async function querySpaceSearch( bucket.push(hit); } - const bots = await prisma.bot.findMany({ - where: { - spaceId: actor.spaceId, - userId: actor.userId, - archivedAt: null, - OR: [ - { name: { contains: query, mode: "insensitive" } }, - { title: { contains: query, mode: "insensitive" } }, - { description: { contains: query, mode: "insensitive" } }, - ], - }, - take: CONVERSATION_HIT_LIMIT, - }); + const [bots, groups, artifacts, groupArtifacts, routines] = await Promise.all([ + prisma.bot.findMany({ + where: { + spaceId: actor.spaceId, + userId: actor.userId, + archivedAt: null, + OR: [ + { name: { contains: query, mode: "insensitive" } }, + { title: { contains: query, mode: "insensitive" } }, + { description: { contains: query, mode: "insensitive" } }, + ], + }, + take: CONVERSATION_HIT_LIMIT, + }), + prisma.chatGroup.findMany({ + where: { + spaceId: actor.spaceId, + userId: actor.userId, + name: { contains: query, mode: "insensitive" }, + }, + take: CONVERSATION_HIT_LIMIT, + }), + prisma.artifact.findMany({ + where: { + spaceId: actor.spaceId, + userId: actor.userId, + groupId: null, + botId: { not: null }, + name: { contains: query, mode: "insensitive" }, + bot: { archivedAt: null }, + }, + include: { bot: { select: { name: true } } }, + take: SEARCH_LIMIT, + }), + prisma.artifact.findMany({ + where: { + spaceId: actor.spaceId, + userId: actor.userId, + groupId: { not: null }, + name: { contains: query, mode: "insensitive" }, + }, + include: { group: { select: { name: true } } }, + take: SEARCH_LIMIT, + }), + prisma.routine.findMany({ + where: { + spaceId: actor.spaceId, + userId: actor.userId, + OR: [ + { name: { contains: query, mode: "insensitive" } }, + { prompt: { contains: query, mode: "insensitive" } }, + ], + bot: { archivedAt: null }, + }, + include: { bot: { select: { name: true } } }, + take: SEARCH_LIMIT, + }), + ]); + for (const bot of bots) { pushInto( conversationHits, @@ -91,14 +138,6 @@ export async function querySpaceSearch( ); } - const groups = await prisma.chatGroup.findMany({ - where: { - spaceId: actor.spaceId, - userId: actor.userId, - name: { contains: query, mode: "insensitive" }, - }, - take: CONVERSATION_HIT_LIMIT, - }); for (const group of groups) { pushInto( conversationHits, @@ -114,27 +153,72 @@ export async function querySpaceSearch( } const contentBudget = Math.max(0, SEARCH_LIMIT - conversationHits.length); + const pattern = `%${query}%`; + + const [botArtifactMessages, groupArtifactMessages, messageRows, groupMessageRows] = + await Promise.all([ + findArtifactMessages( + prisma, + actor, + artifacts.flatMap((artifact) => + artifact.botId && artifact.bot ? [{ id: artifact.id, targetId: artifact.botId }] : [], + ), + "bot", + ), + findArtifactMessages( + prisma, + actor, + groupArtifacts.flatMap((artifact) => + artifact.groupId && artifact.group + ? [{ id: artifact.id, targetId: artifact.groupId }] + : [], + ), + "group", + ), + prisma.$queryRaw< + Array<{ + id: string; + threadId: string; + seq: number; + blocks: Prisma.JsonValue; + botId: string; + botName: string; + }> + >` + SELECT m.id, m."threadId", m.seq, m.blocks, b.id AS "botId", b.name AS "botName" + FROM messages m + INNER JOIN threads t ON t.id = m."threadId" + INNER JOIN bots b ON b.id = t."botId" + WHERE t."spaceId" = ${actor.spaceId} + AND t."userId" = ${actor.userId} + AND b."archivedAt" IS NULL + AND m.blocks::text ILIKE ${pattern} + ORDER BY m."createdAt" DESC + LIMIT ${SEARCH_LIMIT} + `, + prisma.$queryRaw< + Array<{ + id: string; + threadId: string; + seq: number; + blocks: Prisma.JsonValue; + groupId: string; + groupName: string; + }> + >` + SELECT m.id, m."threadId", m.seq, m.blocks, g.id AS "groupId", g.name AS "groupName" + FROM messages m + INNER JOIN threads t ON t.id = m."threadId" + INNER JOIN chat_groups g ON g.id = t."groupId" + WHERE t."spaceId" = ${actor.spaceId} + AND t."userId" = ${actor.userId} + AND t."groupId" IS NOT NULL + AND m.blocks::text ILIKE ${pattern} + ORDER BY m."createdAt" DESC + LIMIT ${SEARCH_LIMIT} + `, + ]); - const artifacts = await prisma.artifact.findMany({ - where: { - spaceId: actor.spaceId, - userId: actor.userId, - groupId: null, - botId: { not: null }, - name: { contains: query, mode: "insensitive" }, - bot: { archivedAt: null }, - }, - include: { bot: { select: { name: true } } }, - take: SEARCH_LIMIT, - }); - const botArtifactMessages = await findArtifactMessages( - prisma, - actor, - artifacts.flatMap((artifact) => - artifact.botId && artifact.bot ? [{ id: artifact.id, targetId: artifact.botId }] : [], - ), - "bot", - ); for (const artifact of artifacts) { if (!artifact.botId || !artifact.bot) continue; const message = botArtifactMessages.get(artifact.id); @@ -155,24 +239,6 @@ export async function querySpaceSearch( ); } - const groupArtifacts = await prisma.artifact.findMany({ - where: { - spaceId: actor.spaceId, - userId: actor.userId, - groupId: { not: null }, - name: { contains: query, mode: "insensitive" }, - }, - include: { group: { select: { name: true } } }, - take: SEARCH_LIMIT, - }); - const groupArtifactMessages = await findArtifactMessages( - prisma, - actor, - groupArtifacts.flatMap((artifact) => - artifact.groupId && artifact.group ? [{ id: artifact.id, targetId: artifact.groupId }] : [], - ), - "group", - ); for (const artifact of groupArtifacts) { if (!artifact.groupId || !artifact.group) continue; const message = groupArtifactMessages.get(artifact.id); @@ -193,19 +259,6 @@ export async function querySpaceSearch( ); } - const routines = await prisma.routine.findMany({ - where: { - spaceId: actor.spaceId, - userId: actor.userId, - OR: [ - { name: { contains: query, mode: "insensitive" } }, - { prompt: { contains: query, mode: "insensitive" } }, - ], - bot: { archivedAt: null }, - }, - include: { bot: { select: { name: true } } }, - take: SEARCH_LIMIT, - }); for (const routine of routines) { pushInto( contentHits, @@ -221,29 +274,6 @@ export async function querySpaceSearch( ); } - const pattern = `%${query}%`; - const messageRows = await prisma.$queryRaw< - Array<{ - id: string; - threadId: string; - seq: number; - blocks: Prisma.JsonValue; - botId: string; - botName: string; - }> - >` - SELECT m.id, m."threadId", m.seq, m.blocks, b.id AS "botId", b.name AS "botName" - FROM messages m - INNER JOIN threads t ON t.id = m."threadId" - INNER JOIN bots b ON b.id = t."botId" - WHERE t."spaceId" = ${actor.spaceId} - AND t."userId" = ${actor.userId} - AND b."archivedAt" IS NULL - AND m.blocks::text ILIKE ${pattern} - ORDER BY m."createdAt" DESC - LIMIT ${SEARCH_LIMIT} - `; - for (const row of messageRows) { pushMessageHits(row.blocks as MessageBlock[], { botId: row.botId, @@ -255,28 +285,6 @@ export async function querySpaceSearch( }); } - const groupMessageRows = await prisma.$queryRaw< - Array<{ - id: string; - threadId: string; - seq: number; - blocks: Prisma.JsonValue; - groupId: string; - groupName: string; - }> - >` - SELECT m.id, m."threadId", m.seq, m.blocks, g.id AS "groupId", g.name AS "groupName" - FROM messages m - INNER JOIN threads t ON t.id = m."threadId" - INNER JOIN chat_groups g ON g.id = t."groupId" - WHERE t."spaceId" = ${actor.spaceId} - AND t."userId" = ${actor.userId} - AND t."groupId" IS NOT NULL - AND m.blocks::text ILIKE ${pattern} - ORDER BY m."createdAt" DESC - LIMIT ${SEARCH_LIMIT} - `; - for (const row of groupMessageRows) { pushMessageHits(row.blocks as MessageBlock[], { groupId: row.groupId, diff --git a/apps/web/e2e/oo-computer-browser.spec.ts b/apps/web/e2e/oo-computer-browser.spec.ts index 949b7c4..e4e7c5d 100644 --- a/apps/web/e2e/oo-computer-browser.spec.ts +++ b/apps/web/e2e/oo-computer-browser.spec.ts @@ -69,7 +69,7 @@ test("Oo browser shows live screen, cursor, cowork click, and user context", asy }); }); - await page.getByRole("button", { name: "Browser", exact: true }).click(); + await page.getByTitle("Agent computer").click(); const browser = page.getByTestId("in-app-browser"); await expect(browser.getByTestId("oo-computer-browser-screen")).toHaveAttribute( "src", diff --git a/apps/web/src/components/DualSurfaceDivider.tsx b/apps/web/src/components/DualSurfaceDivider.tsx index d4b4869..718188e 100644 --- a/apps/web/src/components/DualSurfaceDivider.tsx +++ b/apps/web/src/components/DualSurfaceDivider.tsx @@ -74,7 +74,7 @@ export function DualSurfaceDivider({ onDoubleClick={handleDoubleClick} onKeyDown={handleKeyDown} className={cn( - "group relative flex w-3 md:w-4 shrink-0 cursor-col-resize select-none touch-none items-center justify-center transition-colors bg-border/0 hover:bg-accent/40 active:bg-primary/20 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + "group relative flex w-3 md:w-3 shrink-0 cursor-col-resize select-none touch-none items-center justify-center transition-colors bg-transparent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", disabled && "pointer-events-none cursor-default opacity-50", className, )} @@ -82,8 +82,8 @@ export function DualSurfaceDivider({ >
diff --git a/apps/web/src/lib/oo-audio-bus.ts b/apps/web/src/lib/oo-audio-bus.ts index a8967f2..c7930d3 100644 --- a/apps/web/src/lib/oo-audio-bus.ts +++ b/apps/web/src/lib/oo-audio-bus.ts @@ -5,8 +5,8 @@ type AudioContextCtor = new () => AudioContext; -/** Master output ~−15 LUFS for short procedural peaks. */ -const SFX_MASTER_GAIN = 0.58; +/** Master output ~−12 LUFS for clear, warm procedural peaks. */ +const SFX_MASTER_GAIN = 0.85; /** Duck depth when voice lane active (TTS or mic wash). */ const SFX_DUCK_RATIO = 0.28; const DUCK_RAMP_S = 0.065; @@ -22,8 +22,10 @@ class OoAudioBus { private ctx: AudioContext | null = null; private sfxGain: GainNode | null = null; private voiceHold = 0; + private unlockBound = false; ensureContext(): AudioContext | null { + this.bindGestureUnlock(); if (this.ctx && this.ctx.state !== "closed") return this.ctx; const Ctor = audioContextCtor(); if (!Ctor) return null; @@ -43,6 +45,30 @@ class OoAudioBus { } } + /** Unlocks Web Audio on the user's first click, key, or tap so async SSE events can play audio. */ + bindGestureUnlock(): void { + if (this.unlockBound || typeof window === "undefined" || typeof window.addEventListener !== "function") { + return; + } + this.unlockBound = true; + const unlock = () => { + const ctx = this.ensureContext(); + if (ctx && ctx.state === "suspended") { + void ctx.resume().catch(() => undefined); + } + if (typeof window !== "undefined" && "speechSynthesis" in window) { + try { + window.speechSynthesis.getVoices?.(); + } catch { + // Ignore voice preload errors. + } + } + }; + window.addEventListener("pointerdown", unlock, { capture: true, passive: true }); + window.addEventListener("keydown", unlock, { capture: true, passive: true }); + window.addEventListener("touchstart", unlock, { capture: true, passive: true }); + } + /** Route for oo-sound / oo-vocals / oo-voice-mark oscillators. */ sfxDestination(ctx: AudioContext): AudioNode { if (ctx !== this.ctx) { diff --git a/apps/web/src/lib/oo-sound.ts b/apps/web/src/lib/oo-sound.ts index f798e9f..d39aec6 100644 --- a/apps/web/src/lib/oo-sound.ts +++ b/apps/web/src/lib/oo-sound.ts @@ -68,61 +68,62 @@ function playCueOnContext(ctx: AudioContext, cue: OoSoundCue) { switch (cue) { case "app-start": - sweep(ctx, master, 523.25, 659.25, t0, 0.14, { gain: 0.038, type: "sine" }); - sweep(ctx, master, 659.25, 493.88, t0 + 0.1, 0.18, { gain: 0.032, type: "triangle" }); + sweep(ctx, master, 523.25, 659.25, t0, 0.16, { gain: 0.11, type: "sine" }); + sweep(ctx, master, 659.25, 783.99, t0 + 0.1, 0.2, { gain: 0.095, type: "triangle" }); break; case "interact": - tone(ctx, master, 1046.5, t0, 0.06, { - gain: 0.018, - attack: 0.003, - release: 0.04, + tone(ctx, master, 880, t0, 0.075, { + gain: 0.065, + attack: 0.004, + release: 0.045, type: "triangle", }); break; case "success-resolve": case "work-complete": // mint → gold resolve (~270 ms) - sweep(ctx, master, 523.25, 659.25, t0, 0.12, { gain: 0.042, type: "sine" }); - sweep(ctx, master, 659.25, 783.99, t0 + 0.1, 0.22, { gain: 0.036, type: "triangle" }); - tone(ctx, master, 880, t0 + 0.2, 0.28, { gain: 0.03, type: "sine" }); + sweep(ctx, master, 523.25, 659.25, t0, 0.13, { gain: 0.12, type: "sine" }); + sweep(ctx, master, 659.25, 783.99, t0 + 0.1, 0.22, { gain: 0.105, type: "triangle" }); + tone(ctx, master, 880, t0 + 0.2, 0.28, { gain: 0.09, type: "sine" }); break; case "need-you": - sweep(ctx, master, 220, 330, t0, 0.1, { gain: 0.042, type: "triangle" }); - tone(ctx, master, 277.18, t0 + 0.09, 0.12, { gain: 0.035, release: 0.08 }); + sweep(ctx, master, 261.63, 392, t0, 0.12, { gain: 0.125, type: "triangle" }); + tone(ctx, master, 523.25, t0 + 0.1, 0.18, { gain: 0.11, release: 0.1 }); break; case "listening-start": case "voice_listen": - tone(ctx, master, 220, t0, 0.22, { gain: 0.045, type: "triangle" }); - tone(ctx, master, 330, t0 + 0.04, 0.28, { gain: 0.035 }); + tone(ctx, master, 293.66, t0, 0.2, { gain: 0.11, type: "triangle" }); + tone(ctx, master, 440, t0 + 0.05, 0.24, { gain: 0.095 }); break; case "listening-stop": - tone(ctx, master, 330, t0, 0.18, { gain: 0.03, release: 0.12 }); + tone(ctx, master, 392, t0, 0.16, { gain: 0.085, release: 0.1 }); + tone(ctx, master, 523.25, t0 + 0.06, 0.18, { gain: 0.075, release: 0.11 }); break; case "speaking-start": - tone(ctx, master, 440, t0, 0.2, { gain: 0.04, type: "sine" }); + sweep(ctx, master, 392, 523.25, t0, 0.14, { gain: 0.095, type: "sine" }); break; case "speaking-done": - tone(ctx, master, 523.25, t0, 0.24, { gain: 0.035 }); - tone(ctx, master, 392, t0 + 0.08, 0.3, { gain: 0.028, release: 0.14 }); + tone(ctx, master, 523.25, t0, 0.2, { gain: 0.085 }); + tone(ctx, master, 392, t0 + 0.08, 0.26, { gain: 0.075, release: 0.14 }); break; case "voice_bargein": - sweep(ctx, master, 523.25, 261.63, t0, 0.05, { gain: 0.032, type: "sine" }); + sweep(ctx, master, 523.25, 261.63, t0, 0.07, { gain: 0.09, type: "sine" }); break; case "attention": - tone(ctx, master, 277.18, t0, 0.16, { gain: 0.05, type: "triangle" }); - tone(ctx, master, 233.08, t0 + 0.12, 0.22, { gain: 0.045 }); + tone(ctx, master, 329.63, t0, 0.16, { gain: 0.13, type: "triangle" }); + tone(ctx, master, 261.63, t0 + 0.12, 0.22, { gain: 0.115 }); break; case "message-sent": - tone(ctx, master, 880, t0, 0.1, { gain: 0.022, attack: 0.004, release: 0.06 }); + sweep(ctx, master, 587.33, 880, t0, 0.11, { gain: 0.08, type: "sine" }); break; case "handoff": - tone(ctx, master, 349.23, t0, 0.2, { gain: 0.04 }); - tone(ctx, master, 466.16, t0 + 0.1, 0.24, { gain: 0.035, type: "triangle" }); + tone(ctx, master, 392, t0, 0.18, { gain: 0.11 }); + tone(ctx, master, 523.25, t0 + 0.09, 0.22, { gain: 0.1, type: "triangle" }); break; case "group-spark": - tone(ctx, master, 587.33, t0, 0.11, { gain: 0.032, attack: 0.004, type: "triangle" }); - tone(ctx, master, 739.99, t0 + 0.06, 0.14, { gain: 0.028, type: "sine" }); - tone(ctx, master, 880, t0 + 0.12, 0.1, { gain: 0.022, release: 0.05 }); + tone(ctx, master, 587.33, t0, 0.11, { gain: 0.095, attack: 0.004, type: "triangle" }); + tone(ctx, master, 739.99, t0 + 0.06, 0.14, { gain: 0.085, type: "sine" }); + tone(ctx, master, 880, t0 + 0.12, 0.12, { gain: 0.075, release: 0.06 }); break; case "routine_start": tone(ctx, master, 392, t0, 0.08, { gain: 0.025, attack: 0.005, release: 0.03, type: "sine" }); diff --git a/apps/web/src/lib/oo-vocals.ts b/apps/web/src/lib/oo-vocals.ts index 0856a8e..5c8fda2 100644 --- a/apps/web/src/lib/oo-vocals.ts +++ b/apps/web/src/lib/oo-vocals.ts @@ -3,8 +3,8 @@ import { readOoSoundEnabled } from "./oo-sound-pref"; export type OoVocalReaction = "ouh" | "mhm" | "aaah" | "mm" | "nod-down" | "nod-up" | "hm"; -const COOLDOWN_MIN_MS = 8_000; -const COOLDOWN_MAX_MS = 12_000; +const COOLDOWN_MIN_MS = 1_600; +const COOLDOWN_MAX_MS = 2_800; let lastPlayedAt = 0; let nextCooldownMs = COOLDOWN_MIN_MS; @@ -28,17 +28,23 @@ function vocalFormant( ) { const osc = ctx.createOscillator(); const gain = ctx.createGain(); - const filter = ctx.createBiquadFilter(); - filter.type = "bandpass"; - filter.frequency.value = baseHz * 2.4; - filter.Q.value = 4.5; - osc.type = "triangle"; + const boostedPeak = Math.min(0.22, gainPeak * 3.2); + osc.type = "sine"; osc.frequency.setValueAtTime(baseHz, startAt); + osc.frequency.exponentialRampToValueAtTime(baseHz * 1.12, startAt + duration * 0.75); gain.gain.setValueAtTime(0.0001, startAt); - gain.gain.exponentialRampToValueAtTime(gainPeak, startAt + 0.025); + gain.gain.exponentialRampToValueAtTime(boostedPeak, startAt + 0.02); gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration); - osc.connect(filter); - filter.connect(gain); + if (typeof ctx.createBiquadFilter === "function") { + const filter = ctx.createBiquadFilter(); + filter.type = "bandpass"; + filter.frequency.value = baseHz * 1.6; + filter.Q.value = 1.8; + osc.connect(filter); + filter.connect(gain); + } else { + osc.connect(gain); + } gain.connect(destination); osc.start(startAt); osc.stop(startAt + duration + 0.02); diff --git a/apps/web/src/lib/tts.test.ts b/apps/web/src/lib/tts.test.ts index 23a2b23..8743ab0 100644 --- a/apps/web/src/lib/tts.test.ts +++ b/apps/web/src/lib/tts.test.ts @@ -215,6 +215,53 @@ describe("Speaker", () => { expect(speaker.state.status).toBe("idle"); expect(speaker.state.error).toBeUndefined(); }); + + it("filters out novelty voices and selects a natural German voice for German text", async () => { + stubSelectedSpace("space-voice"); + const speak = vi.fn(); + const cancel = vi.fn(); + const eddy = { name: "Eddy (German (Germany))", lang: "de-DE", localService: true }; + const grandma = { name: "Grandma (German (Germany))", lang: "de-DE", localService: true }; + const anna = { name: "Anna", lang: "de-DE", localService: true }; + vi.stubGlobal( + "SpeechSynthesisUtterance", + class { + onend: (() => void) | null = null; + onerror: (() => void) | null = null; + voice: unknown = null; + lang = ""; + rate = 1; + pitch = 1; + constructor(public text: string) {} + }, + ); + vi.stubGlobal("window", { + speechSynthesis: { + cancel, + getVoices: () => [eddy, grandma, anna], + speak, + }, + localStorage: globalThis.localStorage, + }); + + const speaker = new Speaker(); + vi.spyOn( + speaker as unknown as { prepare: () => Promise