{"record":{"id":"a51cbb13e26efbf6","repo":"jamiepine/voicebox","slug":"could-not-decode-suffix-audio-the-recording-ma","errorCode":null,"errorMessage":"Could not decode {suffix} audio — the recording may be empty or corrupt","messagePattern":"Could not decode (.+?) audio — the recording may be empty or corrupt","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"warning","filePath":"backend/services/captures.py","lineNumber":104,"sourceCode":"        # via ffmpeg, which miniaudio (used inside mlx-audio's whisper) can't.\n        # The decoded array gives us an accurate duration and becomes the\n        # canonical WAV we hand to whisper.\n        try:\n            audio, sr = load_audio(str(raw_path))\n            duration_ms = int((len(audio) / sr) * 1000) if sr else None\n        except Exception as decode_err:\n            logger.warning(\n                \"Could not decode capture %s (%s): %r\", capture_id, suffix, decode_err\n            )\n            audio, sr = None, None\n            duration_ms = None\n\n        if audio is None or sr is None:\n            # Decode failed. Only pass the file straight to whisper if the\n            # source is a format its miniaudio loader can still read — webm,\n            # m4a, etc. would just 500 later. Surface a clean error instead.\n            if suffix not in WHISPER_NATIVE_FORMATS:\n                raise ValueError(\n                    f\"Could not decode {suffix} audio — the recording may be empty or corrupt\"\n                )\n            audio_path = raw_path\n        elif suffix == \".wav\":\n            audio_path = raw_path\n        else:\n            # Transcode to WAV so downstream loaders (miniaudio, soundfile) work\n            # regardless of what format the client shipped.\n            audio_path = config.get_captures_dir() / f\"{capture_id}.wav\"\n            sf.write(str(audio_path), audio, sr, format=\"WAV\")\n            written_files.append(audio_path)\n            with contextlib.suppress(OSError):\n                raw_path.unlink()\n                written_files.remove(raw_path)\n\n        whisper = get_whisper_model()\n        resolved_stt = stt_model or whisper.model_size\n        transcript = await whisper.transcribe(str(audio_path), language, resolved_stt)","sourceCodeStart":86,"sourceCodeEnd":122,"githubUrl":"https://github.com/jamiepine/voicebox/blob/51f49dea198384b4eb6087b72c17057c6eb1c1cd/backend/services/captures.py#L86-L122","documentation":"Raised as ValueError (HTTP 400) inside create_capture when librosa-based load_audio fails to decode the uploaded audio AND the file's suffix is not in WHISPER_NATIVE_FORMATS = ('.wav','.mp3','.flac','.ogg'). The service first tries to decode with load_audio; on failure it only passes the raw file through to Whisper if Whisper's miniaudio loader can read that format natively. For webm/m4a/etc. it surfaces this clean error instead of letting Whisper 500 later. Root cause is empty, truncated, or genuinely corrupt audio in a non-native container.","triggerScenarios":"Uploading a .webm/.m4a/.opus recording that is empty (0 bytes of audio), truncated (recording stopped mid-write), or encoded with a codec the host's ffmpeg/librosa can't decode. The decoded audio array comes back None and the suffix isn't a Whisper-native format, so the guard fires.","commonSituations":"Browser MediaRecorder produced an empty blob (user never spoke / permission glitch), upload was cut off, the host lacks ffmpeg so librosa's audioread fallback can't decode webm/opus, or a transcode step upstream produced a malformed file.","solutions":["On the client, reject empty/very-small recordings before uploading.","Ensure ffmpeg is installed on the server so librosa can decode webm/opus/m4a via audioread.","If the source format is controllable, upload .wav/.mp3/.flac/.ogg which can fall through to Whisper directly.","Re-record; if persistently failing for one file, treat it as corrupt."],"exampleFix":"// before\nconst blob = recorder.getBlob(); // possibly empty\nupload(blob);\n\n// after\nconst blob = recorder.getBlob();\nif (!blob || blob.size < 1024) {\n  toast('Recording is empty; please try again');\n  return;\n}\nupload(blob);","handlingStrategy":"validation","validationCode":"function isLikelyValidRecording(blob) {\n  if (!blob || blob.size < 1024) return false; // empty/truncated\n  if (!/\\.(wav|mp3|m4a|ogg|flac|aac|webm|opus)$/i.test(blob.name || '')) return false;\n  return true;\n}\nif (!isLikelyValidRecording(blob)) { notify('Recording is empty or unsupported'); return; }","typeGuard":"function isDecodableBlob(blob, hasFfmpeg) {\n  if (blob.size < 1024) return false;\n  const native = /\\.(wav|mp3|flac|ogg)$/i;\n  return native.test(blob.name || '') || hasFfmpeg;\n}","tryCatchPattern":"try { await api.createCapture(blob, 'recording'); }\ncatch (e) {\n  if (e.status === 400 && /decode/i.test(e.detail)) {\n    notify('Recording was empty or corrupt; please re-record');\n  } else throw e;\n}","preventionTips":["Discard empty/very short recordings client-side before upload.","Ensure ffmpeg is installed on the server for webm/opus/m4a decode via librosa/audioread.","Prefer uploading wav/mp3/flac/ogg when the host can't guarantee ffmpeg."],"tags":["api","captures","audio","validation","ffmpeg","decode"],"backgroundTag":null,"analyzedSha":"51f49dea198384b4eb6087b72c17057c6eb1c1cd","analyzedAt":"2026-08-12T16:51:42.824Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}