blob: ede75850a6c7886ec9ef8ac1e9e12c50552b0144 [file]
export const VOICE_MAX_TRANSCRIPT_CHARS = 8_000;
export const VOICE_MAX_CAPTURE_DURATION_MS = 60_000;
export const VOICE_MAX_AUDIO_BYTES = 16 * 1024 * 1024;
export const VOICE_MAX_SAMPLE_RATE = 48_000;
export const VOICE_MAX_CHANNELS = 1;
export const VOICE_TTS_MAX_TEXT_CHARS = 4_000;
const CONTROL_CHARS_REGEX = /[\u0000-\u001F\u007F-\u009F]/g;
const WHITESPACE_REGEX = /\s+/g;
export type VoicePermissionStatus =
| 'unknown'
| 'not_determined'
| 'granted'
| 'denied'
| 'restricted'
| 'unsupported';
export type VoiceInputMode = 'off' | 'push_to_talk' | 'toggle_to_record';
export type VoiceSttProvider = 'disabled' | 'local' | 'cloud';
export type VoiceTtsProvider = 'disabled' | 'local' | 'openai' | 'elevenlabs';
export type VoiceTtsPolicy = 'off' | 'manual_preview' | 'inbound_disabled' | 'smart_disabled';
export type VoiceTranscriptSource = 'local_model' | 'cloud_provider';
export type VoiceTranscriptPersistence = 'composer_only' | 'discarded';
export type VoiceReadinessReason =
| 'voice_disabled'
| 'permission_not_granted'
| 'permission_restricted'
| 'local_model_missing'
| 'provider_not_ready'
| 'cloud_not_enabled'
| 'duration_exceeded'
| 'audio_too_large'
| 'invalid_audio_shape'
| 'transcript_empty'
| 'incognito_blocks_persistence'
| 'raw_audio_persistence_forbidden'
| 'telemetry_forbidden';
export interface VoiceCaptureCaps {
maxDurationMs: number;
maxAudioBytes: number;
maxSampleRate: number;
maxChannels: number;
}
export interface VoicePrivacyFlags {
persistAudio: false;
transcriptToMemory: false;
telemetryIncludesRawAudio: false;
telemetryIncludesTranscript: false;
}
export interface VoiceCapabilitySnapshot {
inputMode: VoiceInputMode;
microphonePermission: VoicePermissionStatus;
sttProvider: VoiceSttProvider;
localSttModelReady: boolean;
cloudSttEnabled: boolean;
ttsProvider: VoiceTtsProvider;
ttsPolicy: VoiceTtsPolicy;
captureCaps: VoiceCaptureCaps;
privacy: VoicePrivacyFlags;
readiness: 'disabled' | 'ready' | 'blocked';
reasons: VoiceReadinessReason[];
}
export interface VoiceTranscriptResult {
text: string;
source: VoiceTranscriptSource;
durationMs: number;
sampleRate: number;
channels: number;
confidence?: number;
editableBeforeSend: true;
persistence: VoiceTranscriptPersistence;
}
export interface VoiceCaptureRequest {
mode?: unknown;
permission?: unknown;
durationMs?: unknown;
audioBytes?: unknown;
sampleRate?: unknown;
channels?: unknown;
incognitoActive?: unknown;
}
export interface VoiceTranscriptRequest {
text?: unknown;
source?: unknown;
durationMs?: unknown;
sampleRate?: unknown;
channels?: unknown;
confidence?: unknown;
persistence?: unknown;
editableBeforeSend?: unknown;
}
export interface VoiceTtsRequest {
text?: unknown;
provider?: unknown;
policy?: unknown;
}
export type VoiceNormalizeResult<T> =
| { ok: true; value: T }
| { ok: false; reason: VoiceReadinessReason; error: string };
export function defaultVoiceCapabilitySnapshot(): VoiceCapabilitySnapshot {
return {
inputMode: 'off',
microphonePermission: 'unknown',
sttProvider: 'disabled',
localSttModelReady: false,
cloudSttEnabled: false,
ttsProvider: 'disabled',
ttsPolicy: 'off',
captureCaps: defaultVoiceCaptureCaps(),
privacy: defaultVoicePrivacyFlags(),
readiness: 'disabled',
reasons: ['voice_disabled'],
};
}
export function defaultVoiceCaptureCaps(): VoiceCaptureCaps {
return {
maxDurationMs: VOICE_MAX_CAPTURE_DURATION_MS,
maxAudioBytes: VOICE_MAX_AUDIO_BYTES,
maxSampleRate: VOICE_MAX_SAMPLE_RATE,
maxChannels: VOICE_MAX_CHANNELS,
};
}
export function defaultVoicePrivacyFlags(): VoicePrivacyFlags {
return {
persistAudio: false,
transcriptToMemory: false,
telemetryIncludesRawAudio: false,
telemetryIncludesTranscript: false,
};
}
export function normalizeVoiceInputMode(input: unknown): VoiceNormalizeResult<VoiceInputMode> {
if (input === undefined || input === null || input === '') {
return { ok: true, value: 'off' };
}
if (input === 'off' || input === 'push_to_talk' || input === 'toggle_to_record') {
return { ok: true, value: input };
}
return { ok: false, reason: 'voice_disabled', error: 'Voice input mode is not supported' };
}
export function normalizeVoiceTtsPolicy(input: unknown): VoiceNormalizeResult<VoiceTtsPolicy> {
if (input === undefined || input === null || input === '') {
return { ok: true, value: 'off' };
}
if (
input === 'off' ||
input === 'manual_preview' ||
input === 'inbound_disabled' ||
input === 'smart_disabled'
) {
return { ok: true, value: input };
}
return { ok: false, reason: 'voice_disabled', error: 'Voice TTS policy is not supported' };
}
export function normalizeVoiceTranscriptText(input: unknown): VoiceNormalizeResult<string> {
if (typeof input !== 'string') {
return { ok: false, reason: 'transcript_empty', error: 'Transcript must be a string' };
}
const value = input
.normalize('NFC')
.replace(CONTROL_CHARS_REGEX, ' ')
.replace(WHITESPACE_REGEX, ' ')
.trim();
if (value.length === 0) {
return { ok: false, reason: 'transcript_empty', error: 'Transcript cannot be empty' };
}
const codePoints = Array.from(value);
if (codePoints.length > VOICE_MAX_TRANSCRIPT_CHARS) {
return { ok: true, value: codePoints.slice(0, VOICE_MAX_TRANSCRIPT_CHARS).join('') };
}
return { ok: true, value };
}
export function validateVoiceCaptureRequest(
input: unknown,
): VoiceNormalizeResult<VoiceCaptureCaps> {
const request = asRecord(input);
if (!request.ok) return request;
const mode = normalizeVoiceInputMode(request.value.mode);
if (!mode.ok) return mode;
if (mode.value === 'off') {
return { ok: false, reason: 'voice_disabled', error: 'Voice capture is disabled' };
}
if (request.value.permission === 'denied') {
return {
ok: false,
reason: 'permission_not_granted',
error: 'Microphone permission is denied',
};
}
if (request.value.permission === 'restricted') {
return {
ok: false,
reason: 'permission_restricted',
error: 'Microphone permission is restricted',
};
}
if (request.value.permission !== 'granted') {
return {
ok: false,
reason: 'permission_not_granted',
error: 'Microphone permission is not granted',
};
}
const duration = finiteNumber(
request.value.durationMs,
'duration_exceeded',
'Capture duration must be a finite number',
);
if (!duration.ok) return duration;
if (duration.value > VOICE_MAX_CAPTURE_DURATION_MS) {
return {
ok: false,
reason: 'duration_exceeded',
error: 'Voice capture duration exceeds the configured cap',
};
}
const bytes = finiteNumber(
request.value.audioBytes,
'audio_too_large',
'Audio size must be a finite number',
);
if (!bytes.ok) return bytes;
if (bytes.value > VOICE_MAX_AUDIO_BYTES) {
return {
ok: false,
reason: 'audio_too_large',
error: 'Voice capture audio exceeds the configured cap',
};
}
const sampleRate = finiteNumber(
request.value.sampleRate,
'invalid_audio_shape',
'Sample rate must be a finite number',
);
if (!sampleRate.ok) return sampleRate;
if (sampleRate.value <= 0 || sampleRate.value > VOICE_MAX_SAMPLE_RATE) {
return {
ok: false,
reason: 'invalid_audio_shape',
error: 'Sample rate is outside the supported range',
};
}
const channels = finiteNumber(
request.value.channels,
'invalid_audio_shape',
'Channel count must be a finite number',
);
if (!channels.ok) return channels;
if (
!Number.isInteger(channels.value) ||
channels.value < 1 ||
channels.value > VOICE_MAX_CHANNELS
) {
return {
ok: false,
reason: 'invalid_audio_shape',
error: 'Channel count is outside the supported range',
};
}
return { ok: true, value: defaultVoiceCaptureCaps() };
}
export function validateVoiceTranscriptResult(
input: unknown,
): VoiceNormalizeResult<VoiceTranscriptResult> {
const request = asRecord(input);
if (!request.ok) return request;
const text = normalizeVoiceTranscriptText(request.value.text);
if (!text.ok) return text;
if (request.value.source !== 'local_model' && request.value.source !== 'cloud_provider') {
return { ok: false, reason: 'provider_not_ready', error: 'Transcript source is not supported' };
}
if (request.value.source === 'cloud_provider') {
return {
ok: false,
reason: 'cloud_not_enabled',
error: 'Cloud transcription is disabled by default',
};
}
if (request.value.editableBeforeSend !== true) {
return {
ok: false,
reason: 'transcript_empty',
error: 'Transcript must be editable before send',
};
}
if (request.value.persistence !== 'composer_only' && request.value.persistence !== 'discarded') {
return {
ok: false,
reason: 'raw_audio_persistence_forbidden',
error: 'Transcript persistence is not supported',
};
}
const duration = finiteNumber(
request.value.durationMs,
'duration_exceeded',
'Transcript duration must be a finite number',
);
if (!duration.ok) return duration;
if (duration.value > VOICE_MAX_CAPTURE_DURATION_MS) {
return {
ok: false,
reason: 'duration_exceeded',
error: 'Transcript duration exceeds the configured cap',
};
}
const sampleRate = finiteNumber(
request.value.sampleRate,
'invalid_audio_shape',
'Sample rate must be a finite number',
);
if (!sampleRate.ok) return sampleRate;
if (sampleRate.value <= 0 || sampleRate.value > VOICE_MAX_SAMPLE_RATE) {
return {
ok: false,
reason: 'invalid_audio_shape',
error: 'Transcript sample rate is outside the supported range',
};
}
const channels = finiteNumber(
request.value.channels,
'invalid_audio_shape',
'Channel count must be a finite number',
);
if (!channels.ok) return channels;
if (
!Number.isInteger(channels.value) ||
channels.value < 1 ||
channels.value > VOICE_MAX_CHANNELS
) {
return {
ok: false,
reason: 'invalid_audio_shape',
error: 'Transcript channel count is outside the supported range',
};
}
if (request.value.confidence !== undefined) {
const confidence = finiteNumber(
request.value.confidence,
'invalid_audio_shape',
'Confidence must be a finite number',
);
if (!confidence.ok) return confidence;
if (confidence.value < 0 || confidence.value > 1) {
return {
ok: false,
reason: 'invalid_audio_shape',
error: 'Confidence must be between 0 and 1',
};
}
}
return {
ok: true,
value: {
text: text.value,
source: request.value.source,
durationMs: duration.value,
sampleRate: sampleRate.value,
channels: channels.value,
confidence:
typeof request.value.confidence === 'number' ? request.value.confidence : undefined,
editableBeforeSend: true,
persistence: request.value.persistence,
},
};
}
export function validateVoiceTtsRequest(
input: unknown,
): VoiceNormalizeResult<{ text: string; provider: VoiceTtsProvider; policy: VoiceTtsPolicy }> {
const request = asRecord(input);
if (!request.ok) return request;
const policy = normalizeVoiceTtsPolicy(request.value.policy);
if (!policy.ok) return policy;
if (policy.value !== 'manual_preview') {
return {
ok: false,
reason: 'voice_disabled',
error: 'Automatic voice output is disabled by contract',
};
}
if (
request.value.provider !== 'local' &&
request.value.provider !== 'openai' &&
request.value.provider !== 'elevenlabs'
) {
return { ok: false, reason: 'provider_not_ready', error: 'TTS provider is not ready' };
}
const text = normalizeVoiceTranscriptText(request.value.text);
if (!text.ok) return text;
const codePoints = Array.from(text.value);
return {
ok: true,
value: {
text: codePoints.slice(0, VOICE_TTS_MAX_TEXT_CHARS).join(''),
provider: request.value.provider,
policy: policy.value,
},
};
}
function asRecord(input: unknown): VoiceNormalizeResult<Record<string, unknown>> {
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
return { ok: false, reason: 'voice_disabled', error: 'Voice request must be an object' };
}
return { ok: true, value: input as Record<string, unknown> };
}
function finiteNumber(
input: unknown,
reason: VoiceReadinessReason,
error: string,
): VoiceNormalizeResult<number> {
if (typeof input !== 'number' || !Number.isFinite(input)) {
return { ok: false, reason, error };
}
return { ok: true, value: input };
}