Skip to content
Source
Sign inSign up

speech.ts

139 lines5.6 KB

1import {
2 CLOUD_SPEECH_PROVIDER_NAMES,
3 type CloudSpeechProvider,
4 SPEECH_MAX_OPTIONS_BYTES,
5 type SpeechCustomWords,
6 SpeechTranscriptionOptions,
7} from "@t3tools/contracts";
8import * as Schema from "effect/Schema";
9​
10const decodeOptions = Schema.decodeUnknownSync(Schema.fromJsonString(SpeechTranscriptionOptions));
11const encodeOptions = Schema.encodeSync(Schema.fromJsonString(SpeechTranscriptionOptions));
12​
13export function cloudSpeechDisclosure(provider: CloudSpeechProvider, environment: string) {
14 return provider === "openrouter"
15 ? `After you stop, ${environment} sends the recording to OpenRouter. OpenRouter chooses who runs your model. T3 requests zero data retention; your OpenRouter account may allow safety retention.`
16 : `After you stop, ${environment} sends your recording and vocabulary hints, including project names, to ${CLOUD_SPEECH_PROVIDER_NAMES[provider]}.`;
18​
19// Only offer explicit hints understood by all cloud APIs; other languages can still be detected.
20export const cloudSpeechLanguages = {
21 supportsLanguageDetection: true,
22 languages:
23 "ar bg ca cs da de el en es fa fi fr hi hr hu id it ja ko mk ms nl pl pt ro ru sk sv th tr uk ur vi zh".split(
24 " ",
25 ),
26};
27​
28/** Resolve the language the model can produce from the requested language. */
29export function effectiveSpeechLanguage(
30 model: { readonly languages: readonly string[]; readonly supportsLanguageDetection: boolean },
31 intent: string,
32): string {
33 if (model.languages.length === 1) return model.languages[0]!;
34 if (intent !== "auto" && model.languages.includes(intent)) return intent;
35 if (model.supportsLanguageDetection) return "auto";
36 return model.languages.find((language) => language === "en") ?? model.languages[0] ?? "auto";
38​
39/**
40 * The model first-run setup offers: the selected one, else a recommended one, else any that the
41 * server would really transcribe the person's language with. Without a language hint,
42 * offer only the selected model, without a recommendation.
43 */
44export function chooseSetupSpeechModel<
45 Model extends {
46 readonly id: string;
47 readonly languages: readonly string[];
48 readonly supportsLanguageDetection: boolean;
49 readonly recommended: boolean;
50 },
51>(
52 models: readonly Model[],
53 input: {
54 readonly selectedModelId: string;
55 readonly intent: string;
56 readonly preferred: readonly string[];
57 },
58) {
59 if (input.intent === "auto" && input.preferred.length === 0) {
60 const model = models.find(({ id }) => id === input.selectedModelId);
61 return model && { model, language: null };
62 }
63 for (const language of input.intent === "auto" ? input.preferred : [input.intent]) {
64 const capable = models.filter((model) => {
65 const effective = effectiveSpeechLanguage(model, input.intent);
66 return effective === language || (effective === "auto" && model.languages.includes(language));
67 });
68 const model =
69 capable.find(({ id }) => id === input.selectedModelId) ??
70 capable.find(({ recommended }) => recommended) ??
71 capable[0];
72 if (model) return { model, language };
73 }
74 return undefined;
76​
77const vocabularyKey = (value: string) => value.trim().normalize("NFC").toLocaleLowerCase();
78​
79/**
80 * Project spellings win over personal ones. Hint terms, such as the project name or the
81 * correction cue, come first so the 100-entry wire limit cannot drop them.
82 */
83export function resolveSpeechVocabulary(input: {
84 readonly personal: SpeechCustomWords;
85 readonly project: SpeechCustomWords;
86 readonly hints?: ReadonlyArray<string>;
87}): SpeechCustomWords {
88 const known = new Set(
89 [...input.project, ...input.personal].flatMap(({ term, aliases }) =>
90 [term, ...aliases].map(vocabularyKey),
91 ),
92 );
93 // A hint that is already a dictionary entry keeps that entry and its aliases in place.
94 const hints = (input.hints ?? []).flatMap((hint) => {
95 const term = hint.replace(/\s+/g, " ").trim();
96 return term.length > 0 && term.length <= 50 && !known.has(vocabularyKey(term))
97 ? [{ term, aliases: [] }]
98 : [];
99 });
100 const seen = new Set<string>();
101 return [...hints, ...input.project, ...input.personal]
102 .flatMap(({ term, aliases }) => {
103 if (seen.has(vocabularyKey(term))) return [];
104 seen.add(vocabularyKey(term));
105 const uniqueAliases = aliases.filter((alias) => {
106 if (seen.has(vocabularyKey(alias))) return false;
107 seen.add(vocabularyKey(alias));
108 return true;
109 });
110 return [{ term, aliases: uniqueAliases }];
111 })
112 .slice(0, 100);
114​
115/** Binary requests contain a four-byte JSON length, UTF-8 options, then unencoded PCM. */
116export function encodeSpeechPcmRequest(
117 pcm: Uint8Array,
118 options: SpeechTranscriptionOptions,
119): Uint8Array<ArrayBuffer> {
120 const metadata = new TextEncoder().encode(encodeOptions(options));
121 if (metadata.byteLength > SPEECH_MAX_OPTIONS_BYTES)
122 throw new Error("Speech options are too large.");
123 const body = new Uint8Array(4 + metadata.byteLength + pcm.byteLength);
124 new DataView(body.buffer).setUint32(0, metadata.byteLength, true);
125 body.set(metadata, 4);
126 body.set(pcm, 4 + metadata.byteLength);
127 return body;
129​
130export function decodeSpeechPcmRequest(body: Uint8Array) {
131 if (body.byteLength < 4) throw new Error("Missing speech options.");
132 const length = new DataView(body.buffer, body.byteOffset, body.byteLength).getUint32(0, true);
133 if (length > SPEECH_MAX_OPTIONS_BYTES || 4 + length >= body.byteLength)
134 throw new Error("Invalid speech options length.");
135 const options = decodeOptions(
136 new TextDecoder("utf-8", { fatal: true }).decode(body.subarray(4, 4 + length)),
137 );
138 return { pcm: body.subarray(4 + length), options };