From 1be72ab5ece657fac42a025a73cf0dddc32b3854 Mon Sep 17 00:00:00 2001 From: neilruaro-camb Date: Thu, 9 Apr 2026 12:47:59 +0800 Subject: [PATCH] feat: add CAMB AI text-to-speech for note summaries --- .../pages/recording/RecordingDesktop.tsx | 11 ++- .../pages/recording/RecordingMobile.tsx | 8 ++- convex/camb.ts | 69 +++++++++++++++++++ convex/schema.ts | 2 + convex/together.ts | 8 +++ 5 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 convex/camb.ts diff --git a/components/pages/recording/RecordingDesktop.tsx b/components/pages/recording/RecordingDesktop.tsx index 7742ac8..af16808 100644 --- a/components/pages/recording/RecordingDesktop.tsx +++ b/components/pages/recording/RecordingDesktop.tsx @@ -20,6 +20,7 @@ export default function RecordingDesktop({ transcription, title, _creationTime, + summaryAudioUrl, } = note; const [originalIsOpen, setOriginalIsOpen] = useState(true); @@ -85,7 +86,15 @@ export default function RecordingDesktop({
{transcription ? ( -
{originalIsOpen ? transcription : summary}
+
+
{originalIsOpen ? transcription : summary}
+ {!originalIsOpen && summaryAudioUrl && ( +
+

Listen to summary

+
+ )} +
) : ( // Loading state for transcript
    diff --git a/components/pages/recording/RecordingMobile.tsx b/components/pages/recording/RecordingMobile.tsx index e2f0c60..1100753 100644 --- a/components/pages/recording/RecordingMobile.tsx +++ b/components/pages/recording/RecordingMobile.tsx @@ -12,7 +12,7 @@ export default function RecordingMobile({ note: Doc<'notes'>; actionItems: Doc<'actionItems'>[]; }) { - const { summary, transcription, title, _creationTime } = note; + const { summary, transcription, title, _creationTime, summaryAudioUrl } = note; const [transcriptOpen, setTranscriptOpen] = useState(true); const [summaryOpen, setSummaryOpen] = useState(false); const [actionItemOpen, setActionItemOpen] = useState(false); @@ -78,6 +78,12 @@ export default function RecordingMobile({ {summaryOpen && (
    {summary} + {summaryAudioUrl && ( +
    +

    Listen to summary

    +
    + )}
    )} {actionItemOpen && ( diff --git a/convex/camb.ts b/convex/camb.ts new file mode 100644 index 0000000..522df3f --- /dev/null +++ b/convex/camb.ts @@ -0,0 +1,69 @@ +("use node"); + +import { internalAction, internalMutation } from "./_generated/server"; +import { v } from "convex/values"; +import { internal } from "./_generated/api"; + +const CAMB_API_BASE = "https://client.camb.ai/apis"; + +function getCambApiKey(): string { + const key = process.env.CAMB_API_KEY; + if (!key) { + throw new Error("CAMB_API_KEY environment variable is not set"); + } + return key; +} + +// Generate TTS audio of a note summary using CAMB AI +export const generateSpeech = internalAction({ + args: { + id: v.id("notes"), + text: v.string(), + }, + handler: async (ctx, args) => { + const apiKey = getCambApiKey(); + + const res = await fetch(`${CAMB_API_BASE}/tts-stream`, { + method: "POST", + headers: { + "x-api-key": apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + text: args.text, + voice_id: 147320, + language: "en-us", + speech_model: "mars-flash", + output_configuration: { format: "wav" }, + }), + }); + + if (!res.ok) { + throw new Error(`CAMB TTS failed: ${await res.text()}`); + } + + const audioBuffer = await res.arrayBuffer(); + const blob = new Blob([audioBuffer], { type: "audio/wav" }); + + const storageId = await ctx.storage.store(blob); + + await ctx.runMutation(internal.camb.saveSummaryAudio, { + id: args.id, + summaryAudioFileId: storageId, + }); + }, +}); + +export const saveSummaryAudio = internalMutation({ + args: { + id: v.id("notes"), + summaryAudioFileId: v.id("_storage"), + }, + handler: async (ctx, args) => { + const url = await ctx.storage.getUrl(args.summaryAudioFileId); + await ctx.db.patch(args.id, { + summaryAudioFileId: args.summaryAudioFileId, + summaryAudioUrl: url ?? undefined, + }); + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 9e29a9b..1438da6 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -10,6 +10,8 @@ export default defineSchema({ transcription: v.optional(v.string()), summary: v.optional(v.string()), embedding: v.optional(v.array(v.float64())), + summaryAudioFileId: v.optional(v.id('_storage')), + summaryAudioUrl: v.optional(v.string()), generatingTranscript: v.boolean(), generatingTitle: v.boolean(), generatingActionItems: v.boolean(), diff --git a/convex/together.ts b/convex/together.ts index 6c79ac8..a1c3f66 100644 --- a/convex/together.ts +++ b/convex/together.ts @@ -128,6 +128,14 @@ export const saveSummary = internalMutation({ await ctx.db.patch(id, { generatingActionItems: false, }); + + // Generate TTS audio of the summary using CAMB AI + if (summary && summary !== 'Summary failed to generate') { + await ctx.scheduler.runAfter(0, internal.camb.generateSpeech, { + id, + text: summary, + }); + } }, });