// Speech-to-text (dictation) transcription. Mirrors the web builder's // SpeechAPI.transcribe + SpeechToTextButton flow: POST a multipart audio blob to // the backend and get `{ text }` back. Native produces m4a/mp4 (not webm), and // the multipart shape matches `attachmentUpload.ts` (`{ name, type, uri }` blob, // Bearer auth, mobile_native platform header). export const SPEECH_NO_TEXT_ERROR = 'No speech detected. Please try again.'; export const SPEECH_RATE_LIMIT_ERROR = 'Too many requests. Please wait a moment and try again.'; export const SPEECH_TRANSCRIBE_FAILED_ERROR = 'Failed to transcribe audio. Please try again.'; const DEFAULT_AUDIO_FILENAME = 'audio.m4a'; const DEFAULT_AUDIO_MIME_TYPE = 'audio/m4a'; export type SpeechToTextFile = { uri: string; mimeType?: string; name?: string; }; export async function transcribeAudio({ authToken, baseUrl, file, headers, }: { authToken: string; baseUrl: string; file: SpeechToTextFile; // Session headers (e.g. X-Active-App-Id / X-Active-Workspace-Id) — the backend // resolves the active workspace's rate limits and tier from these, so dictation // must send them like the other runtime clients do (else it falls back to the // user's personal-workspace limits). headers?: Record; }): Promise { const formData = new FormData(); formData.append('file', { name: file.name || DEFAULT_AUDIO_FILENAME, type: file.mimeType || DEFAULT_AUDIO_MIME_TYPE, uri: file.uri, } as unknown as Blob); const response = await fetch(`${normalizeBaseUrl(baseUrl)}/api/speech/transcribe`, { body: formData, headers: { ...headers, Authorization: `Bearer ${authToken}`, 'X-Client-Platform': 'mobile_native', }, method: 'POST', }); if (response.status === 429) { throw new Error(SPEECH_RATE_LIMIT_ERROR); } const parsed = await response.json().catch(() => null); const body = parsed && typeof parsed === 'object' ? parsed : {}; if (!response.ok) { throw new Error(body.message || body.detail || SPEECH_TRANSCRIBE_FAILED_ERROR); } const text = typeof body.text === 'string' ? body.text.trim() : ''; if (!text) { // Empty/whitespace transcript is the backend's "no speech" signal — surface // the web builder's copy rather than a generic failure. throw new Error(SPEECH_NO_TEXT_ERROR); } return text; } function normalizeBaseUrl(url: string) { return url.trim().replace(/\/+$/, ''); } /** Elapsed-recording label for the dictation bar, e.g. 0:07, 1:32. */ export function formatRecordingDuration(totalSeconds: number): string { const clamped = Math.max(0, Math.floor(totalSeconds)); const minutes = Math.floor(clamped / 60); const seconds = clamped % 60; return `${minutes}:${String(seconds).padStart(2, '0')}`; } function clamp01(value: number): number { return Math.min(1, Math.max(0, value)); } /** * Fast-attack / slow-decay envelope for waveform levels: a loud sample snaps the * display up immediately, silence lets it fall off gradually — the standard * metering ballistics that keep a voice waveform from flickering. */ export function smoothLevel(previousDisplay: number, sample: number, decay = 0.72): number { return Math.max(clamp01(sample), clamp01(previousDisplay) * decay); } /** Map a 0..1 level onto a waveform bar height in px. */ export function levelToWaveHeight(level: number, minHeight: number, maxHeight: number): number { return minHeight + clamp01(level) * (maxHeight - minHeight); } /** * Synthetic mic-level source for hosts whose recorder can't meter (and the web * demo without mic permission): a bounded random walk with occasional speech-like * bursts, so the waveform still reads as alive. `random` is injectable for tests. */ export function createSyntheticLevelSource(random: () => number = Math.random): () => number { let level = 0.25; return () => { const burst = random() < 0.08 ? random() * 0.5 : 0; level += (random() - 0.5) * 0.22 + burst - level * 0.08; level = Math.min(1, Math.max(0.05, level)); return level; }; }