Skip to content
Open
6 changes: 6 additions & 0 deletions apps/code/src/main/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ app.commandLine.appendSwitch("enable-logging", "file");
app.commandLine.appendSwitch("log-file", chromiumLogPath);
app.commandLine.appendSwitch("log-level", "0");

// Allow programmatic audio playback without a prior user gesture. The agent
// speaks (and completion sounds ring) autonomously, with no click at that
// moment, so Chromium's default gesture requirement would silently reject
// HTMLMediaElement.play(). Must be set before app "ready".
app.commandLine.appendSwitch("autoplay-policy", "no-user-gesture-required");

// In dev, expose the renderer over CDP (:9222 by default) for the
// test-electron-app skill. electron-vite launches Electron itself, so this is
// set in-process rather than via a CLI flag. POSTHOG_CODE_CDP_PORT matches the
Expand Down
5 changes: 5 additions & 0 deletions apps/code/src/main/di/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ import type {
ISecureStoreService,
SECURE_STORE_SERVICE,
} from "@posthog/workspace-server/services/secure-store/identifiers";
import type {
ISpeechSynthesizer,
SPEECH_SYNTHESIZER_SERVICE,
} from "@posthog/workspace-server/services/speech/identifiers";
import type {
SUSPENSION_FILE_WATCHER,
SUSPENSION_SERVICE,
Expand Down Expand Up @@ -460,6 +464,7 @@ export interface MainBindings {
[MAIN_SECURE_STORE_BACKEND]: typeof rendererStore;
[MAIN_SECURE_STORE_SERVICE]: SecureStoreService;
[SECURE_STORE_SERVICE]: ISecureStoreService;
[SPEECH_SYNTHESIZER_SERVICE]: ISpeechSynthesizer;
[LOGS_SERVICE]: ILogsService;
[MAIN_ENCRYPTION_SERVICE]: EncryptionService;
[MAIN_DISCORD_PRESENCE_SERVICE]: DiscordPresenceService;
Expand Down
6 changes: 6 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ import { SECURE_STORE_SERVICE } from "@posthog/workspace-server/services/secure-
import { shellModule } from "@posthog/workspace-server/services/shell/shell.module";
import { skillsModule } from "@posthog/workspace-server/services/skills/skills.module";
import { skillsMarketplaceModule } from "@posthog/workspace-server/services/skills-marketplace/skills-marketplace.module";
import { SPEECH_SYNTHESIZER_SERVICE } from "@posthog/workspace-server/services/speech/identifiers";
import {
SUSPENSION_FILE_WATCHER,
SUSPENSION_SERVICE,
Expand Down Expand Up @@ -258,6 +259,7 @@ import { DiscordPresenceService } from "../services/discord-presence/service";
import { EncryptionService } from "../services/encryption/service";
import { SecureStoreService } from "../services/secure-store/service";
import { settingsStore } from "../services/settingsStore";
import { ElevenLabsSpeechService } from "../services/speech/service";
import { WorkspaceServerService } from "../services/workspace-server/service";
import { getUserDataDir, isDevBuild } from "../utils/env";
import { logger } from "../utils/logger";
Expand Down Expand Up @@ -717,6 +719,10 @@ container
.to(SecureStoreService)
.inSingletonScope();
container.bind(SECURE_STORE_SERVICE).toService(MAIN_SECURE_STORE_SERVICE);
container
.bind(SPEECH_SYNTHESIZER_SERVICE)
.to(ElevenLabsSpeechService)
.inSingletonScope();
container.bind(LOGS_SERVICE).toDynamicValue((ctx) => {
const ws = ctx.get<WorkspaceClient>(MAIN_WORKSPACE_CLIENT);
return {
Expand Down
96 changes: 96 additions & 0 deletions apps/code/src/main/services/speech/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { logger } from "@main/utils/logger";
import {
type ISecureStoreService,
SECURE_STORE_SERVICE,
} from "@posthog/workspace-server/services/secure-store/identifiers";
import {
ELEVENLABS_API_KEY_STORE_KEY,
type ISpeechSynthesizer,
type SpeechSynthesisResult,
} from "@posthog/workspace-server/services/speech/identifiers";
import { inject, injectable } from "inversify";

const log = logger.scope("speech");

// Expressive default voice (Eleven v3). Overridable per call via voiceId.
const DEFAULT_VOICE_ID = "goT3UYdM9bhm0n2lmKQx";
const MODEL_ID = "eleven_v3";
const OUTPUT_FORMAT = "mp3_44100_128";
// Cache synthesized audio by voice+text so repeated lines (e.g. the "finished"
// backstop for a given task) aren't re-billed/re-fetched. Bounded LRU — audio
// is ~100KB, so this caps at a few MB.
const CACHE_MAX = 32;

/**
* ElevenLabs-backed speech synthesizer. Reads the API key from encrypted secure
* storage and returns MP3 bytes to the renderer, which plays them (host-neutral
* playback). When no key is configured or synthesis fails, returns null so the
* renderer falls back to the system voice. Best-effort — never throws.
*/
@injectable()
export class ElevenLabsSpeechService implements ISpeechSynthesizer {
// voice+text -> audioBase64; insertion-ordered for LRU eviction.
private readonly cache = new Map<string, string>();

constructor(
@inject(SECURE_STORE_SERVICE)
private readonly secureStore: ISecureStoreService,
) {}

async synthesize(
text: string,
voiceId?: string,
): Promise<SpeechSynthesisResult | null> {
const apiKey = this.secureStore.getItem(ELEVENLABS_API_KEY_STORE_KEY);
if (!apiKey) {
log.info("No ElevenLabs key configured — using system voice");
return null;
}
const voice = voiceId?.trim() || DEFAULT_VOICE_ID;

const cacheKey = `${voice}:${text}`;
const cached = this.cache.get(cacheKey);
if (cached !== undefined) {
// Refresh recency (delete + re-set moves it to the end).
this.cache.delete(cacheKey);
this.cache.set(cacheKey, cached);
log.info(`Speech cache hit (voice=${voice}, chars=${text.length})`);
return { audioBase64: cached, mimeType: "audio/mpeg" };
}

log.info(
`Synthesizing via ElevenLabs (voice=${voice}, chars=${text.length})`,
);
try {
const res = await fetch(
`https://api.elevenlabs.io/v1/text-to-speech/${voice}?output_format=${OUTPUT_FORMAT}`,
{
method: "POST",
headers: { "xi-api-key": apiKey, "content-type": "application/json" },
body: JSON.stringify({
text,
model_id: MODEL_ID,
voice_settings: { stability: 0.5 },
}),
},
);
if (!res.ok) {
const detail = await res.text().catch(() => "");
log.warn(
`ElevenLabs failed: HTTP ${res.status} ${detail.slice(0, 300)}`,
);
return null;
}
const buffer = Buffer.from(await res.arrayBuffer());
const audioBase64 = buffer.toString("base64");
this.cache.set(cacheKey, audioBase64);
if (this.cache.size > CACHE_MAX) {
this.cache.delete(this.cache.keys().next().value as string);
}
return { audioBase64, mimeType: "audio/mpeg" };
} catch (error) {
log.warn("ElevenLabs synthesis error", error);
return null;
}
}
}
2 changes: 2 additions & 0 deletions apps/code/src/main/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { shellRouter } from "@posthog/host-router/routers/shell.router";
import { skillsRouter } from "@posthog/host-router/routers/skills.router";
import { slackIntegrationRouter } from "@posthog/host-router/routers/slack-integration.router";
import { sleepRouter } from "@posthog/host-router/routers/sleep.router";
import { speechRouter } from "@posthog/host-router/routers/speech.router";
import { suspensionRouter } from "@posthog/host-router/routers/suspension.router";
import { uiRouter } from "@posthog/host-router/routers/ui.router";
import { updatesRouter } from "@posthog/host-router/routers/updates.router";
Expand Down Expand Up @@ -98,6 +99,7 @@ export const trpcRouter = router({
suspension: suspensionRouter,
secureStore: secureStoreRouter,
shell: shellRouter,
speech: speechRouter,
skills: skillsRouter,
slackIntegration: slackIntegrationRouter,
ui: uiRouter,
Expand Down
2 changes: 2 additions & 0 deletions apps/code/src/renderer/desktop-contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { githubConnectModule } from "@posthog/core/integrations/githubConnect.mo
import { onboardingModule } from "@posthog/core/onboarding/onboarding.module";
import { setupCoreModule } from "@posthog/core/setup/setup.module";
import { skillsCoreModule } from "@posthog/core/skills/skills.module";
import { speechCoreModule } from "@posthog/core/speech/speech.module";
import { CONTRIBUTION } from "@posthog/di/contribution";
import { agentUiModule } from "@posthog/ui/features/agent/agent.module";
import { authUiModule } from "@posthog/ui/features/auth/auth.module";
Expand Down Expand Up @@ -48,6 +49,7 @@ export function registerDesktopContributions(): void {
setupCoreModule,
setupUiModule,
skillsCoreModule,
speechCoreModule,
workspaceUiModule,
]) {
container.load(module);
Expand Down
102 changes: 102 additions & 0 deletions apps/code/src/renderer/desktop-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,20 @@ import {
type SessionService,
} from "@posthog/core/sessions/sessionService";
import { SETUP_STORE } from "@posthog/core/setup/identifiers";
import {
SPEECH_SETTINGS_PROVIDER,
SPEECH_USER_NAME_PROVIDER,
type SpeechSettingsProvider,
type UserNameProvider,
} from "@posthog/core/speech/identifiers";
import { resolveService } from "@posthog/di/container";
import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger";
import {
type INotifications,
NOTIFICATIONS_SERVICE,
type NotificationTarget,
} from "@posthog/platform/notifications";
import { type ISpeech, SPEECH_SERVICE } from "@posthog/platform/speech";
import {
type Adapter,
AUTORESEARCH_FLAG,
Expand All @@ -58,6 +65,7 @@ import {
AUTH_SIDE_EFFECTS,
type IAuthSideEffects,
} from "@posthog/ui/features/auth/identifiers";
import { authKeys } from "@posthog/ui/features/auth/useCurrentUser";
import {
FEATURE_FLAGS,
type FeatureFlags,
Expand All @@ -77,22 +85,36 @@ import {
ACTIVE_VIEW_PROVIDER,
type IActiveView,
type INotificationSettings,
type ISpeechNotifySettings,
NOTIFICATION_SETTINGS_PROVIDER,
SPEECH_NOTIFY_SETTINGS,
} from "@posthog/ui/features/notifications/identifiers";
import { OnboardingGithubConnectClient } from "@posthog/ui/features/onboarding/githubConnectClientImpl";
import {
AGENT_PROMPT_SENDER,
type AgentPromptSender,
} from "@posthog/ui/features/sessions/agentPromptSender";
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
import {
type ISpeechKeyStore,
SPEECH_KEY_STORE,
} from "@posthog/ui/features/settings/speechKeyStore";
import { getCurrentMatches } from "@posthog/ui/router/navigationBridge";
import { HEDGEHOG_MODE_HOST } from "@posthog/ui/shell/hedgehogModeHost";
import { posthogFeatureFlags } from "@posthog/ui/shell/posthogAnalyticsImpl";
import type { ImperativeQueryClient } from "@posthog/ui/shell/queryClient";
import { IMPERATIVE_QUERY_CLIENT } from "@posthog/ui/shell/queryClient";
import {
FILE_PATH_RESOLVER,
type FilePathResolver,
} from "@posthog/ui/utils/getFilePath";
import {
isSpeechSupported,
playAudioBase64,
speakSystemVoice,
stopSpeech,
} from "@posthog/ui/utils/speech";
import { ELEVENLABS_API_KEY_STORE_KEY } from "@posthog/workspace-server/services/speech/identifiers";
import { container } from "@renderer/di/container";
import { RendererAuthSideEffects } from "@renderer/platform-adapters/auth-side-effects";
import { gitCacheKeyProvider } from "@renderer/platform-adapters/git-cache-keys";
Expand Down Expand Up @@ -329,6 +351,86 @@ container.bind<FileWatcherClient>(FILE_WATCHER_CLIENT).toConstantValue({
stop: (repoPath: string) => trpcClient.fileWatcher.stop.mutate({ repoPath }),
});

// Spoken notifications: synthesize in the host (ElevenLabs, key stays there),
// play in the renderer from a blob URL (host-neutral). Fall back to the system
// voice when no key is set or synthesis fails. speak() resolves when playback
// ends, so the core queue serializes utterances.
const speechLog = logger.scope("speech-adapter");
container.bind<ISpeech>(SPEECH_SERVICE).toConstantValue({
isSupported: () => isSpeechSupported(),
speak: async (text, opts) => {
try {
const result = await hostTrpcClient.speech.synthesize.query({
text,
voiceId: opts?.voiceId || undefined,
});
if (result?.audioBase64) {
await playAudioBase64(result.audioBase64, result.mimeType);
return;
}
} catch (err) {
speechLog.warn("Synthesis failed; using system voice", err);
}
await speakSystemVoice(text);
},
stop: () => stopSpeech(),
});

container
.bind<SpeechSettingsProvider>(SPEECH_SETTINGS_PROVIDER)
.toConstantValue({
get: () => {
const s = useSettingsStore.getState();
return {
enabled: s.spokenNotifications,
voiceId: s.elevenLabsVoiceId || undefined,
};
},
});

container.bind<UserNameProvider>(SPEECH_USER_NAME_PROVIDER).toConstantValue({
getFirstName: () => {
try {
const qc = container.get<ImperativeQueryClient>(IMPERATIVE_QUERY_CLIENT);
for (const [, data] of qc.getQueriesData({
queryKey: authKeys.currentUsers(),
})) {
const first = (
data as { first_name?: string | null } | undefined
)?.first_name?.trim();
if (first) return first;
}
} catch {
// best-effort — no name means we just skip the "Hey <name>" prefix
}
return undefined;
},
});

container.bind<ISpeechKeyStore>(SPEECH_KEY_STORE).toConstantValue({
save: (apiKey) =>
hostTrpcClient.secureStore.setItem
.query({ key: ELEVENLABS_API_KEY_STORE_KEY, value: apiKey })
.then(() => {}),
clear: () =>
hostTrpcClient.secureStore.removeItem
.query({ key: ELEVENLABS_API_KEY_STORE_KEY })
.then(() => {}),
});

container.bind<ISpeechNotifySettings>(SPEECH_NOTIFY_SETTINGS).toConstantValue({
get: () => {
const s = useSettingsStore.getState();
return {
enabled: s.spokenNotifications,
needsInput: s.spokenNotifyNeedsInput,
completion: s.spokenNotifyCompletion,
progress: s.spokenNotifyProgress,
focusMode: s.spokenFocusMode,
};
},
});

container
.bind<FeatureFlags>(FEATURE_FLAGS)
.toConstantValue(posthogFeatureFlags);
Expand Down
18 changes: 18 additions & 0 deletions apps/code/src/renderer/di/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ import {
import { type ISetupStore, SETUP_STORE } from "@posthog/core/setup/identifiers";
import { SKILLS_WORKSPACE_CLIENT } from "@posthog/core/skills/identifiers";
import type { SkillsWorkspaceClient } from "@posthog/core/skills/teamSkillsService";
import {
SPEECH_SETTINGS_PROVIDER,
SPEECH_USER_NAME_PROVIDER,
type SpeechSettingsProvider,
type UserNameProvider,
} from "@posthog/core/speech/identifiers";
import {
TASK_CREATION_EFFECTS,
TASK_CREATION_HOST,
Expand Down Expand Up @@ -137,6 +143,7 @@ import {
type INotifications,
NOTIFICATIONS_SERVICE,
} from "@posthog/platform/notifications";
import { type ISpeech, SPEECH_SERVICE } from "@posthog/platform/speech";
import {
AUTH_SIDE_EFFECTS,
type IAuthSideEffects,
Expand Down Expand Up @@ -188,7 +195,9 @@ import {
ACTIVE_VIEW_PROVIDER,
type IActiveView,
type INotificationSettings,
type ISpeechNotifySettings,
NOTIFICATION_SETTINGS_PROVIDER,
SPEECH_NOTIFY_SETTINGS,
} from "@posthog/ui/features/notifications/identifiers";
import {
AGENT_PROMPT_SENDER,
Expand All @@ -202,6 +211,10 @@ import {
DEV_MODE_CLIENT,
type DevModeClient,
} from "@posthog/ui/features/settings/devModeClient";
import {
type ISpeechKeyStore,
SPEECH_KEY_STORE,
} from "@posthog/ui/features/settings/speechKeyStore";
import {
SHELL_CLIENT,
type ShellClient,
Expand Down Expand Up @@ -316,6 +329,11 @@ export interface RendererBindings {
[NOTIFICATIONS_SERVICE]: INotifications;
[NOTIFICATION_SETTINGS_PROVIDER]: INotificationSettings;
[ACTIVE_VIEW_PROVIDER]: IActiveView;
[SPEECH_SERVICE]: ISpeech;
[SPEECH_SETTINGS_PROVIDER]: SpeechSettingsProvider;
[SPEECH_USER_NAME_PROVIDER]: UserNameProvider;
[SPEECH_NOTIFY_SETTINGS]: ISpeechNotifySettings;
[SPEECH_KEY_STORE]: ISpeechKeyStore;
[FILE_WATCHER_CLIENT]: FileWatcherClient;
[FEATURE_FLAGS]: FeatureFlags;
[AUTH_SIDE_EFFECTS]: IAuthSideEffects;
Expand Down
Loading
Loading