packages/shared/src/speech.ts
import {
CLOUD_SPEECH_PROVIDER_NAMES,
type CloudSpeechProvider,
SPEECH_MAX_OPTIONS_BYTES,
type SpeechCustomWords,
SpeechTranscriptionOptions,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
const decodeOptions = Schema.decodeUnknownSync(Schema.fromJsonString(SpeechTranscriptionOptions));
const encodeOptions = Schema.encodeSync(Schema.fromJsonString(SpeechTranscriptionOptions));
export function cloudSpeechDisclosure(provider: CloudSpeechProvider, environment: string) {
return provider === "openrouter"
? `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.`
: `After you stop, ${environment} sends your recording and vocabulary hints, including project names, to ${CLOUD_SPEECH_PROVIDER_NAMES[provider]}.`;
}
// Only offer explicit hints understood by all cloud APIs; other languages can still be detected.
export const cloudSpeechLanguages = {
supportsLanguageDetection: true,
languages:
"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(
" ",
),
};
/** Resolve the language the model can produce from the requested language. */
export function effectiveSpeechLanguage(
model: { readonly languages: readonly string[]; readonly supportsLanguageDetection: boolean },
intent: string,
): string {
if (model.languages.length === 1) return model.languages[0]!;
if (intent !== "auto" && model.languages.includes(intent)) return intent;
if (model.supportsLanguageDetection) return "auto";
return model.languages.find((language) => language === "en") ?? model.languages[0] ?? "auto";
}
/**
* The model first-run setup offers: the selected one, else a recommended one, else any that the
* server would really transcribe the person's language with. Without a language hint,
* offer only the selected model, without a recommendation.
*/
export function chooseSetupSpeechModel<
Model extends {
readonly id: string;
readonly languages: readonly string[];
readonly supportsLanguageDetection: boolean;
readonly recommended: boolean;
},
>(
models: readonly Model[],
input: {
readonly selectedModelId: string;
readonly intent: string;
readonly preferred: readonly string[];
},
) {
if (input.intent === "auto" && input.preferred.length === 0) {
const model = models.find(({ id }) => id === input.selectedModelId);
return model && { model, language: null };
}
for (const language of input.intent === "auto" ? input.preferred : [input.intent]) {
const capable = models.filter((model) => {
const effective = effectiveSpeechLanguage(model, input.intent);
return effective === language || (effective === "auto" && model.languages.includes(language));
});
const model =
capable.find(({ id }) => id === input.selectedModelId) ??
capable.find(({ recommended }) => recommended) ??
capable[0];
if (model) return { model, language };
}
return undefined;
}
const vocabularyKey = (value: string) => value.trim().normalize("NFC").toLocaleLowerCase();
/**
* Project spellings win over personal ones. Hint terms, such as the project name or the
* correction cue, come first so the 100-entry wire limit cannot drop them.
*/
export function resolveSpeechVocabulary(input: {
readonly personal: SpeechCustomWords;
readonly project: SpeechCustomWords;
readonly hints?: ReadonlyArray<string>;
}): SpeechCustomWords {
const known = new Set(
[...input.project, ...input.personal].flatMap(({ term, aliases }) =>
[term, ...aliases].map(vocabularyKey),
),
);
// A hint that is already a dictionary entry keeps that entry and its aliases in place.
const hints = (input.hints ?? []).flatMap((hint) => {
const term = hint.replace(/\s+/g, " ").trim();
return term.length > 0 && term.length <= 50 && !known.has(vocabularyKey(term))
? [{ term, aliases: [] }]
: [];
});
const seen = new Set<string>();
return [...hints, ...input.project, ...input.personal]
.flatMap(({ term, aliases }) => {
if (seen.has(vocabularyKey(term))) return [];
seen.add(vocabularyKey(term));
const uniqueAliases = aliases.filter((alias) => {
if (seen.has(vocabularyKey(alias))) return false;
seen.add(vocabularyKey(alias));
return true;
});
return [{ term, aliases: uniqueAliases }];
})
.slice(0, 100);
}
/** Binary requests contain a four-byte JSON length, UTF-8 options, then unencoded PCM. */
export function encodeSpeechPcmRequest(
pcm: Uint8Array,
options: SpeechTranscriptionOptions,
): Uint8Array<ArrayBuffer> {
const metadata = new TextEncoder().encode(encodeOptions(options));
if (metadata.byteLength > SPEECH_MAX_OPTIONS_BYTES)
throw new Error("Speech options are too large.");
const body = new Uint8Array(4 + metadata.byteLength + pcm.byteLength);
new DataView(body.buffer).setUint32(0, metadata.byteLength, true);
body.set(metadata, 4);
body.set(pcm, 4 + metadata.byteLength);
return body;
}
export function decodeSpeechPcmRequest(body: Uint8Array) {
if (body.byteLength < 4) throw new Error("Missing speech options.");
const length = new DataView(body.buffer, body.byteOffset, body.byteLength).getUint32(0, true);
if (length > SPEECH_MAX_OPTIONS_BYTES || 4 + length >= body.byteLength)
throw new Error("Invalid speech options length.");
const options = decodeOptions(
new TextDecoder("utf-8", { fatal: true }).decode(body.subarray(4, 4 + length)),
);
return { pcm: body.subarray(4 + length), options };
}