Repository navigation
Expand file tree
/
Copy pathvoice-token.ts
More file actions
105 lines (91 loc) · 4.68 KB
/
Copy pathvoice-token.ts
File metadata and controls
105 lines (91 loc) · 4.68 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
// Signed, short-lived tokens that tie one piece of opponent speech to the
// user it was written for. The ai function issues them with each voice reply;
// the voice function verifies them before it spends anything. The endpoint
// can therefore speak only text the server wrote, never arbitrary input.
import { MAX_VOICE_SEGMENTS, splitSpokenSegments, stripAudioTags, toSpokenText } from "./voice-tags.ts";
export type VoiceFeature = "sparring" | "crossfire";
export interface VoiceSegment {
/** Spoken text for ElevenLabs, audio tags included. */
text: string;
token: string;
}
export interface VoicePayload {
segments: VoiceSegment[];
/**
* Spoken text past the last signed segment, without tags. Only the
* browser voice reads it, so a very long reply is heard in full without
* raising the ElevenLabs cost of one reply.
*/
overflow?: string;
}
/** Long enough to cover a full sparring speech played segment by segment. */
export const VOICE_TOKEN_TTL_MS = 30 * 60_000;
/**
* The ai function signs and the voice function verifies, possibly on hosts
* whose clocks differ a little. A token that looks this far ahead of the
* verifier's clock still counts as just issued.
*/
export const VOICE_TOKEN_CLOCK_SKEW_MS = 30_000;
/** How many audio tags one reply may carry after rationing. */
export const MAX_VOICE_TAGS: Record<VoiceFeature, number> = {
sparring: 6,
crossfire: 1,
};
export function isVoiceFeature(value: unknown): value is VoiceFeature {
return value === "sparring" || value === "crossfire";
}
let signingKey: Promise<CryptoKey> | undefined;
function importHmacKey(secret: BufferSource): Promise<CryptoKey> {
return crypto.subtle.importKey("raw", secret, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
}
// Read lazily so tests can set the environment before the first token.
function voiceSigningKey(): Promise<CryptoKey> {
signingKey ??= (async () => {
const root = Deno.env.get("SPARRING_INTEGRITY_SECRET")?.trim() || Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (!root) throw new Error("Missing voice signing secret");
const encoder = new TextEncoder();
// Domain-separated from the sparring and Crossfire signing keys.
const derived = await crypto.subtle.sign("HMAC", await importHmacKey(encoder.encode(root)), encoder.encode("DebateLab voice token key v1"));
return importHmacKey(derived);
})();
return signingKey;
}
async function voiceMac(userId: string, feature: VoiceFeature, text: string, issuedAt: number): Promise<string> {
const payload = JSON.stringify({ domain: "debatelab-voice-v1", userId, feature, issuedAt, text });
const signature = new Uint8Array(await crypto.subtle.sign("HMAC", await voiceSigningKey(), new TextEncoder().encode(payload)));
return Array.from(signature, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
export async function createVoiceToken(userId: string, feature: VoiceFeature, text: string, now = Date.now()): Promise<string> {
return `${now}.${await voiceMac(userId, feature, text, now)}`;
}
export async function verifyVoiceToken(userId: string, feature: VoiceFeature, text: string, token: string, now = Date.now()): Promise<boolean> {
const [rawIssuedAt, suppliedMac, ...extra] = token.split(".");
const issuedAt = Number(rawIssuedAt);
if (extra.length > 0 || !Number.isInteger(issuedAt) || !/^[a-f0-9]{64}$/.test(suppliedMac ?? "")) return false;
const age = now - issuedAt;
if (age < -VOICE_TOKEN_CLOCK_SKEW_MS || age > VOICE_TOKEN_TTL_MS) return false;
const expectedMac = await voiceMac(userId, feature, text, issuedAt);
let mismatch = expectedMac.length ^ suppliedMac.length;
for (let index = 0; index < expectedMac.length; index += 1) mismatch |= expectedMac.charCodeAt(index) ^ suppliedMac.charCodeAt(index);
return mismatch === 0;
}
/**
* Split a model reply into what people read and what the voice performs.
* `clean` has no audio tag. `voice` holds signed segments for the voice
* function and any unsigned overflow for the browser voice, or is absent
* when nothing speakable remains.
*/
export async function prepareVoiceReply(
userId: string,
feature: VoiceFeature,
modelText: string,
): Promise<{ clean: string; voice?: VoicePayload }> {
const clean = stripAudioTags(modelText);
const texts = splitSpokenSegments(toSpokenText(modelText, MAX_VOICE_TAGS[feature]));
if (texts.length === 0) return { clean };
const now = Date.now();
const signed = texts.slice(0, MAX_VOICE_SEGMENTS);
const segments = await Promise.all(signed.map(async (text) => ({ text, token: await createVoiceToken(userId, feature, text, now) })));
const overflow = stripAudioTags(texts.slice(MAX_VOICE_SEGMENTS).join(" "));
return { clean, voice: { segments, ...(overflow ? { overflow } : {}) } };
}