jamiepine/voicebox · warning · Error

captures.noTranscriptError

Error message

captures.noTranscriptError

What it means

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.

Source

Thrown at app/src/components/CapturesTab/CapturesTab.tsx:265

    profiles?.[0] ||
    null;
  const playAsVoiceId = playAsVoice?.id ?? null;

  const deleteMutation = useMutation({
    mutationFn: async (captureId: string) => apiClient.deleteCapture(captureId),
    onSuccess: () => {
      setDeleteDialogOpen(false);
      queryClient.invalidateQueries({ queryKey: ['captures'] });
    },
    onError: (err: Error) => {
      toast({ title: t('captures.toast.deleteFailed'), description: err.message, variant: 'destructive' });
    },
  });

  const playAsMutation = useMutation({
    mutationFn: async ({ capture, voice }: { capture: CaptureResponse; voice: VoiceProfileResponse }) => {
      const text = capture.transcript_refined || capture.transcript_raw;
      if (!text.trim()) throw new Error(t('captures.noTranscriptError'));
      const language = (capture.language || voice.language) as LanguageCode;
      // Preset profiles (Kokoro etc.) reject the qwen default — honor the
      // profile's stored engine preference. Cloned profiles without an
      // override fall through to whatever the backend picks.
      const engine = voice.default_engine as
        | 'qwen' | 'qwen_custom_voice' | 'luxtts' | 'chatterbox'
        | 'chatterbox_turbo' | 'tada' | 'kokoro'
        | undefined;
      return apiClient.generateSpeech({
        profile_id: voice.id,
        text,
        language,
        engine,
      });
    },
    onSuccess: (result) => {
      // /generate is queue-based — it returns a generating row with an empty
      // audio_path. Hand the id to the global SSE handler which polls

View on GitHub (pinned to 51f49dea19)

Solutions

  1. Disable the 'Play as' button when (transcript_refined || transcript_raw).trim() is empty.
  2. Wait for transcription to settle before enabling playback actions (check capture status field).
  3. Show an inline empty-state message on the capture explaining it has no transcript yet.

Example fix

// before
<button onClick={() => playAsMutation.mutate({ capture, voice })}>Play</button>
// after
const hasTranscript = !!(capture.transcript_refined || capture.transcript_raw || '').trim();
<button disabled={!hasTranscript} onClick={() => playAsMutation.mutate({ capture, voice })}>Play</button>;
Defensive patterns

Strategy: validation

Validate before calling

function captureHasTranscript(c: CaptureResponse): boolean {
  return !!(c.transcript_refined || c.transcript_raw || '').trim();
}
// Disable Play-as when false:
const canPlay = captureHasTranscript(capture) && !!voice;

Try / catch

const text = (capture.transcript_refined || capture.transcript_raw || '').trim();
if (!text) {
  toast({ title: t('captures.noTranscriptError') });
  return;
}
try {
  await apiClient.generateSpeech({ profile_id: voice.id, text, language });
} catch (e) {
  toast({ title: t('captures.toast.deleteFailed'), description: (e as Error).message, variant: 'destructive' });
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of jamiepine/voicebox@51f49dea19 (2026-08-12). Data as JSON: /api/errors/8cc9c131d25d67de. Report an issue: GitHub.