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 }, + "prepare", + ).mockRejectedValue(new Error("offline")); + + const pending = speaker.speak("Ich habe das Projekt für dich aktualisiert."); + await vi.waitFor(() => expect(speak).toHaveBeenCalledOnce()); + const utterance = speak.mock.calls[0]?.[0] as { + voice?: { name: string }; + lang?: string; + onend?: () => void; + }; + expect(utterance.lang).toBe("de-DE"); + expect(utterance.voice?.name).toBe("Anna"); + utterance.onend?.(); + await pending; + }); }); describe("UtteranceStreamer", () => { diff --git a/apps/web/src/lib/tts.ts b/apps/web/src/lib/tts.ts index 3848fa6..dd8170b 100644 --- a/apps/web/src/lib/tts.ts +++ b/apps/web/src/lib/tts.ts @@ -1,4 +1,5 @@ import { ClauseSegmenter, readBoundedResponseBytes, speakable } from "@rakazo/core"; +import { ooAudioBus } from "./oo-audio-bus.js"; import { rpc, selectedSpaceId, withSpaceHeaders } from "./rpc.js"; import { readUiLocale } from "./ui-locale.js"; @@ -77,7 +78,19 @@ export class Speaker { if (this.snapshot.status !== "idle" || this.snapshot.error) this.set(IDLE); } + private bufferSource: AudioBufferSourceNode | null = null; + private teardownAudio() { + if (this.bufferSource) { + try { + this.bufferSource.onended = null; + this.bufferSource.stop(); + this.bufferSource.disconnect(); + } catch { + // Ignore already-stopped buffer source errors. + } + this.bufferSource = null; + } if (this.audio) { this.audio.pause(); this.audio.src = ""; @@ -226,16 +239,51 @@ export class Speaker { audio.onended = null; audio.onerror = null; if (this.settlePlayback === done) this.settlePlayback = null; - if (this.audio === audio) this.teardownAudio(); + if (this.audio === audio || this.bufferSource) this.teardownAudio(); resolve(ok); }; this.settlePlayback = done; audio.onended = () => done(true); - audio.onerror = () => done(false); - audio.play().catch(() => done(false)); + audio.onerror = () => { + void this.playViaWebAudio(blob, live).then(done); + }; + audio.play().catch(() => { + void this.playViaWebAudio(blob, live).then(done); + }); }); } + private async playViaWebAudio(blob: Blob, live: () => boolean): Promise { + if (!live()) return false; + const ctx = ooAudioBus.ensureContext(); + if ( + !ctx || + typeof ctx.decodeAudioData !== "function" || + typeof ctx.createBufferSource !== "function" + ) { + return false; + } + try { + const arrayBuffer = await blob.arrayBuffer(); + if (!live()) return false; + const audioBuffer = await ctx.decodeAudioData(arrayBuffer.slice(0)); + if (!live()) return false; + return await new Promise((resolve) => { + const source = ctx.createBufferSource(); + source.buffer = audioBuffer; + source.connect(ctx.destination); + this.bufferSource = source; + source.onended = () => { + if (this.bufferSource === source) this.bufferSource = null; + resolve(true); + }; + source.start(0); + }); + } catch { + return false; + } + } + async tryBrowserFallback( text: string, opts: SpeakOptions, @@ -256,6 +304,7 @@ export class Speaker { return new Promise((resolve) => { try { window.speechSynthesis.cancel(); + window.speechSynthesis.resume?.(); const utterance = new SpeechSynthesisUtterance(text); const voices = window.speechSynthesis.getVoices?.() ?? []; const uiLocale = readUiLocale(); @@ -269,21 +318,26 @@ export class Speaker { const langPrefix = isGerman ? "de" : uiLocale.split("-")[0] || "en"; utterance.lang = isGerman ? "de-DE" : langPrefix === "en" ? "en-US" : uiLocale; - const langVoices = voices.filter((v) => - v.lang.toLowerCase().replace(/_/g, "-").startsWith(langPrefix), + const allLangVoices = voices.filter((v) => + (v.lang ?? "").toLowerCase().replace(/_/g, "-").startsWith(langPrefix), ); + const noveltyHint = + /\b(?:eddy|flo|grandma|grandpa|reed|rocko|sandy|shelley|albert|bad news|bahh|bells|boing|bubbles|cellos|deranged|good news|hysterical|junior|organ|superstar|trinoids|whisper|zarvox|fred|kathy|ralph)\b/i; + const naturalLangVoices = allLangVoices.filter((v) => !noveltyHint.test(v.name ?? "")); + const langVoices = naturalLangVoices.length > 0 ? naturalLangVoices : allLangVoices; const wantsMale = /^dm_/i.test(voiceId); - const wantsFemale = /^df_/i.test(voiceId); - const maleHint = /yannick|martin|markus|stefan|viktor|hans|daniel|thomas|male/i; - const femaleHint = /anna|petra|helena|marlene|vicki|katja|siri|eloquence|female/i; + const wantsFemale = /^df_/i.test(voiceId) || (!wantsMale && isGerman); + const maleHint = + /yannick|martin|markus|stefan|viktor|hans|daniel|thomas|florian|conrad|male/i; + const femaleHint = /anna|petra|helena|marlene|vicki|katja|seraphina|amala|siri|female/i; const matchedVoice = (wantsMale && - (langVoices.find((v) => !v.localService && maleHint.test(v.name)) || - langVoices.find((v) => maleHint.test(v.name)))) || + (langVoices.find((v) => !v.localService && maleHint.test(v.name ?? "")) || + langVoices.find((v) => maleHint.test(v.name ?? "")))) || (wantsFemale && - (langVoices.find((v) => !v.localService && femaleHint.test(v.name)) || - langVoices.find((v) => femaleHint.test(v.name)))) || + (langVoices.find((v) => !v.localService && femaleHint.test(v.name ?? "")) || + langVoices.find((v) => femaleHint.test(v.name ?? "")))) || langVoices.find((v) => !v.localService) || langVoices[0] || voices[0]; diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index c3d1ac1..c6a357d 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -75,7 +75,6 @@ import { ArrowLeft, ArrowRight, Calendar, - Globe, PanelRight, Pencil, Plus, @@ -1228,17 +1227,41 @@ export function ShellPage() { const { agentSkills, setAgentSkills, refreshAgentSkills } = useAgentSkills(); + const resolveBotVoiceId = useCallback( + (botId: string | undefined, currentVoiceId?: string | null): string => { + if (currentVoiceId && /^d[fm]_/i.test(currentVoiceId)) return currentVoiceId; + const germanVoices = [ + "df_eva", + "dm_thorsten", + "df_ramona", + "dm_karlsson", + "df_kerstin", + ] as const; + if (!botId) return "df_eva"; + let hash = 0; + for (let i = 0; i < botId.length; i += 1) { + hash = (hash * 31 + botId.charCodeAt(i)) >>> 0; + } + return germanVoices[hash % germanVoices.length] ?? "df_eva"; + }, + [], + ); + useEffect(() => { void rpc.voice .status() .then(async (status) => { if (!status.configured) { - const defaultVoiceId = - typeof navigator !== "undefined" && /^de\b/i.test(navigator.language) - ? "df_eva" - : "af_heart"; await rpc.voice - .connect({ provider: "kokoro", apiKey: "local-kokoro", voiceId: defaultVoiceId }) + .connect({ provider: "kokoro", apiKey: "local-kokoro", voiceId: "df_eva" }) + .catch(() => undefined); + const refreshed = await rpc.voice.status().catch(() => status); + setVoiceStatus(refreshed); + return; + } + if (!status.voiceId || /^a[fm]_/i.test(status.voiceId)) { + await rpc.voice + .setVoice({ provider: status.provider ?? "kokoro", voiceId: "df_eva" }) .catch(() => undefined); const refreshed = await rpc.voice.status().catch(() => status); setVoiceStatus(refreshed); @@ -1255,6 +1278,34 @@ export function ShellPage() { }; }, []); + useEffect(() => { + if (!bots.length) return; + const migratedKey = "rakazo:voice-de-default-v2"; + try { + if (localStorage.getItem(migratedKey) === "1") return; + localStorage.setItem(migratedKey, "1"); + } catch { + return; + } + const staleBots = bots.filter( + (b) => b.autoSpeak === false || !b.voiceId || /^a[fm]_/i.test(b.voiceId), + ); + if (!staleBots.length) return; + void Promise.all( + staleBots.map((b) => + rpc.bots + .update({ + botId: b.id, + autoSpeak: true, + voiceId: resolveBotVoiceId(b.id, b.voiceId), + }) + .catch(() => undefined), + ), + ).then(() => { + void refreshBots().catch(() => undefined); + }); + }, [bots, refreshBots, resolveBotVoiceId]); + useEffect(() => { const currentSnapshot = inGroup ? snapshot?.groupId === groupId @@ -1296,7 +1347,8 @@ export function ShellPage() { const text = spokenSummaryFromBlocks(lastBot.blocks); if (!text) return; autoSpoken.current = lastBot.id; - void speaker.speak(text, { botId: speakerBotId, messageId: lastBot.id }); + const voiceId = resolveBotVoiceId(speakerBotId, speakerBot?.voiceId ?? active?.voiceId); + void speaker.speak(text, { botId: speakerBotId, voiceId, messageId: lastBot.id }); }, [ snapshot, inGroup, @@ -1306,6 +1358,7 @@ export function ShellPage() { coordinatorBot?.id, bots, callOpen, + resolveBotVoiceId, ]); useEffect(() => { @@ -2953,9 +3006,11 @@ export function ShellPage() { } const text = spokenSummaryFromBlocks(message.blocks) || speechFromBlocks(message.blocks); const id = message.botId ?? activeBotId.current ?? defaultSpeakerId ?? coordinatorBot?.id; - if (text && id) void speaker.speak(text, { botId: id, messageId: message.id }); + const msgBot = id ? bots.find((b) => b.id === id) : undefined; + const voiceId = resolveBotVoiceId(id, msgBot?.voiceId); + if (text && id) void speaker.speak(text, { botId: id, voiceId, messageId: message.id }); }, - [coordinatorBot?.id, defaultSpeakerId], + [bots, coordinatorBot?.id, defaultSpeakerId, resolveBotVoiceId], ); async function createGroup(input: { name: string; botIds: string[] }) { @@ -3322,8 +3377,16 @@ export function ShellPage() { {!botsSidebarCollapsed ? (
+ className="group absolute -right-3 top-0 bottom-0 w-3 cursor-col-resize z-50 hidden md:flex items-center justify-center" + > +
+
) : null} {activeNavPillar === "heute" ? (
@@ -4057,29 +4120,6 @@ export function ShellPage() { disabled={dualSurface.isMobile} className="hidden md:flex" /> - {active || groupComputerBotId ? (
) : null} - { - setReplyTarget(message); - setReplyQuote(null); - }} - onQuote={(message, quote) => { - setReplyTarget(message); - setReplyQuote(quote); - }} - onReact={reactToMessage} - onJumpToMessage={jumpToReplyMessage} - onOpenPeerMessages={(peer) => { - setPeerConversation(peer); - }} - memberName={resolveTranscriptMemberName} - peerBot={resolveSpeakerBot} - onRefresh={refreshActiveThread} - onBotChanged={refreshBots} - onAddRoutine={addSkillRoutine} - voiceReady={Boolean(voiceStatus?.ready)} - speakingMessageId={speakingMessageId} - onSpeak={speakMessage} - onRestartTask={sendMessage} - onSendQueuedNow={sendQueuedMessageNow} - onRemoveQueuedMessage={removeQueuedMessage} - /> +
+ { + setReplyTarget(message); + setReplyQuote(null); + }} + onQuote={(message, quote) => { + setReplyTarget(message); + setReplyQuote(quote); + }} + onReact={reactToMessage} + onJumpToMessage={jumpToReplyMessage} + onOpenPeerMessages={(peer) => { + setPeerConversation(peer); + }} + memberName={resolveTranscriptMemberName} + peerBot={resolveSpeakerBot} + onRefresh={refreshActiveThread} + onBotChanged={refreshBots} + onAddRoutine={addSkillRoutine} + voiceReady={Boolean(voiceStatus?.ready)} + speakingMessageId={speakingMessageId} + onSpeak={speakMessage} + onRestartTask={sendMessage} + onSendQueuedNow={sendQueuedMessageNow} + onRemoveQueuedMessage={removeQueuedMessage} + /> + {recordingSkill ? ( +
+ + Teaching in progress. Stop teaching before sending a new message. + +
+ ) : null} + {(active || activeGroup) && !showProjectTimeline ? ( + { + startVoiceEncounter(); + } + : undefined + } + voiceReady={Boolean(voiceStatus?.ready)} + voiceColor={voiceCallBot?.color} + replyTarget={activeReplyTarget} + replyQuote={activeReplyQuote} + replyTargetName={replyTargetName} + onClearReply={clearReply} + mentionTargets={composerMentionTargets} + agentSkills={agentSkills} + onSlashOpen={refreshAgentSkills} + onConnectModel={ + bootstrapMe?.needsModel ? () => openSettings("models") : undefined + } + onAutopilot={startAutopilot} + onSlashAction={(action) => { + if (action === "chat-settings") { + setPanel(inGroup ? "group-settings" : "settings"); + return; + } + if (action === "settings-general") { + openSettings("general"); + return; + } + if (action === "settings-usage") { + void rpc.usage + .summary() + .then(setUsage) + .catch(() => undefined); + openSettings("usage"); + } + }} + /> + ) : null} +
)} {activeGroup?.kind === "main" && !inProject && !showProjectTimeline ? ( @@ -4271,72 +4381,6 @@ export function ShellPage() { onOpenInSpace={openSpaceChat} /> ) : null} - {recordingSkill ? ( -
- Teaching in progress. Stop teaching before sending a new message. -
- ) : null} - {(active || activeGroup) && !showProjectTimeline ? ( - { - startVoiceEncounter(); - } - : undefined - } - voiceReady={Boolean(voiceStatus?.ready)} - voiceColor={voiceCallBot?.color} - replyTarget={activeReplyTarget} - replyQuote={activeReplyQuote} - replyTargetName={replyTargetName} - onClearReply={clearReply} - mentionTargets={composerMentionTargets} - agentSkills={agentSkills} - onSlashOpen={refreshAgentSkills} - onConnectModel={ - bootstrapMe?.needsModel ? () => openSettings("models") : undefined - } - onAutopilot={startAutopilot} - onSlashAction={(action) => { - if (action === "chat-settings") { - setPanel(inGroup ? "group-settings" : "settings"); - return; - } - if (action === "settings-general") { - openSettings("general"); - return; - } - if (action === "settings-usage") { - void rpc.usage - .summary() - .then(setUsage) - .catch(() => undefined); - openSettings("usage"); - } - }} - /> - ) : null} )} @@ -4366,10 +4410,10 @@ export function ShellPage() { data-panel={panel ?? "closed"} data-auto={panel !== null && panel === autoPanel ? "" : undefined} style={canvasStyle} - className={`absolute inset-0 z-30 flex min-h-0 shrink-0 flex-col overflow-hidden bg-background md:relative md:inset-auto md:z-20 md:border-0 md:rounded-none md:shadow-none ${ + className={`absolute inset-0 z-30 flex min-h-0 shrink-0 flex-col overflow-hidden bg-sidebar md:relative md:inset-auto md:z-20 md:rounded-2xl md:border md:border-sidebar-border md:shadow-2xl ${ isPanelVisible ? "w-full max-w-none md:max-w-none opacity-100" - : "pointer-events-none w-0 opacity-0 md:opacity-0 md:w-0" + : "pointer-events-none w-0 opacity-0 md:opacity-0 md:w-0 md:border-0" } ${!(workspacePaneOpen ? isResizingWorkspacePane : isResizingRight) ? "transition-[width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none" : ""}`} >
-
+
{onConnectModel ? (
) : null}
{coalescedMessages.map((message) => { @@ -883,7 +883,7 @@ export const Transcript = memo(function Transcript({ aria-hidden={!showJumpToLatest} tabIndex={showJumpToLatest ? 0 : -1} onClick={jumpToLatest} - className={`absolute bottom-4 left-1/2 z-20 grid h-9 w-9 -translate-x-1/2 place-items-center rounded-full border border-border bg-muted/95 text-foreground/75 shadow-md backdrop-blur transition-[opacity,transform,background-color] duration-200 ease-[cubic-bezier(.22,1,.36,1)] hover:bg-border motion-reduce:transition-none ${ + className={`absolute bottom-24 left-1/2 z-20 grid h-9 w-9 -translate-x-1/2 place-items-center rounded-full border border-border bg-muted/95 text-foreground/75 shadow-md backdrop-blur transition-[opacity,transform,background-color] duration-200 ease-[cubic-bezier(.22,1,.36,1)] hover:bg-border md:bottom-28 motion-reduce:transition-none ${ showJumpToLatest ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-2 opacity-0" @@ -896,7 +896,7 @@ export const Transcript = memo(function Transcript({ type="button" data-testid="needs-you-jump" onClick={jumpToAsk} - className="absolute bottom-4 left-1/2 z-20 inline-flex h-9 -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-muted/95 px-3.5 text-ui-body-sm font-medium text-foreground shadow-md backdrop-blur hover:bg-border" + className="absolute bottom-24 left-1/2 z-20 inline-flex h-9 -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-muted/95 px-3.5 text-ui-body-sm font-medium text-foreground shadow-md backdrop-blur hover:bg-border md:bottom-28" >