{"record":{"id":"8cc9c131d25d67de","repo":"jamiepine/voicebox","slug":"captures-notranscripterror","errorCode":null,"errorMessage":"captures.noTranscriptError","messagePattern":"captures\\.noTranscriptError","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"warning","filePath":"app/src/components/CapturesTab/CapturesTab.tsx","lineNumber":265,"sourceCode":"    profiles?.[0] ||\n    null;\n  const playAsVoiceId = playAsVoice?.id ?? null;\n\n  const deleteMutation = useMutation({\n    mutationFn: async (captureId: string) => apiClient.deleteCapture(captureId),\n    onSuccess: () => {\n      setDeleteDialogOpen(false);\n      queryClient.invalidateQueries({ queryKey: ['captures'] });\n    },\n    onError: (err: Error) => {\n      toast({ title: t('captures.toast.deleteFailed'), description: err.message, variant: 'destructive' });\n    },\n  });\n\n  const playAsMutation = useMutation({\n    mutationFn: async ({ capture, voice }: { capture: CaptureResponse; voice: VoiceProfileResponse }) => {\n      const text = capture.transcript_refined || capture.transcript_raw;\n      if (!text.trim()) throw new Error(t('captures.noTranscriptError'));\n      const language = (capture.language || voice.language) as LanguageCode;\n      // Preset profiles (Kokoro etc.) reject the qwen default — honor the\n      // profile's stored engine preference. Cloned profiles without an\n      // override fall through to whatever the backend picks.\n      const engine = voice.default_engine as\n        | 'qwen' | 'qwen_custom_voice' | 'luxtts' | 'chatterbox'\n        | 'chatterbox_turbo' | 'tada' | 'kokoro'\n        | undefined;\n      return apiClient.generateSpeech({\n        profile_id: voice.id,\n        text,\n        language,\n        engine,\n      });\n    },\n    onSuccess: (result) => {\n      // /generate is queue-based — it returns a generating row with an empty\n      // audio_path. Hand the id to the global SSE handler which polls","sourceCodeStart":247,"sourceCodeEnd":283,"githubUrl":"https://github.com/jamiepine/voicebox/blob/51f49dea198384b4eb6087b72c17057c6eb1c1cd/app/src/components/CapturesTab/CapturesTab.tsx#L247-L283","documentation":"Thrown by the 'Play as' mutation in CapturesTab when a capture has no text. It reads `capture.transcript_refined || capture.transcript_raw`, trims, and throws the i18n key captures.noTranscriptError if empty before calling apiClient.generateSpeech(). This prevents sending an empty string to the TTS backend. The message is a translation key, not user-facing prose; the onError handler turns other failures into a destructive toast.","triggerScenarios":"User clicks 'Play as <voice>' on a capture whose transcript_refined and transcript_raw are both empty or whitespace-only. Common for captures that failed transcription or are still processing.","commonSituations":"A capture finished recording but transcription hasn't completed (transcript fields still empty). Transcription returned an empty result. A capture was imported without a transcript.","solutions":["Disable the 'Play as' button when (transcript_refined || transcript_raw).trim() is empty.","Wait for transcription to settle before enabling playback actions (check capture status field).","Show an inline empty-state message on the capture explaining it has no transcript yet."],"exampleFix":"// before\n<button onClick={() => playAsMutation.mutate({ capture, voice })}>Play</button>\n// after\nconst hasTranscript = !!(capture.transcript_refined || capture.transcript_raw || '').trim();\n<button disabled={!hasTranscript} onClick={() => playAsMutation.mutate({ capture, voice })}>Play</button>;","handlingStrategy":"validation","validationCode":"function captureHasTranscript(c: CaptureResponse): boolean {\n  return !!(c.transcript_refined || c.transcript_raw || '').trim();\n}\n// Disable Play-as when false:\nconst canPlay = captureHasTranscript(capture) && !!voice;","typeGuard":null,"tryCatchPattern":"const text = (capture.transcript_refined || capture.transcript_raw || '').trim();\nif (!text) {\n  toast({ title: t('captures.noTranscriptError') });\n  return;\n}\ntry {\n  await apiClient.generateSpeech({ profile_id: voice.id, text, language });\n} catch (e) {\n  toast({ title: t('captures.toast.deleteFailed'), description: (e as Error).message, variant: 'destructive' });\n}","preventionTips":["Disable Play-as until a non-empty transcript exists.","Wait for transcription status to settle before enabling playback.","Keep the throw, but pair it with a disabled button so it rarely fires."],"tags":["ui","validation","i18n","captures","tts","react","typescript"],"backgroundTag":null,"analyzedSha":"51f49dea198384b4eb6087b72c17057c6eb1c1cd","analyzedAt":"2026-08-12T16:51:42.824Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}