From dfce75f948a77919a0ccdbeaa588dfdb1cd8cf11 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 19:53:22 +0000 Subject: [PATCH] fix: produce valid audio chunks when transcribing files >25MB The old chunking logic split raw file bytes at arbitrary boundaries, producing invalid audio files that Whisper rejects. Web used blob.slice() on WebM containers; native split base64-encoded M4A at random offsets. Fix: Web now decodes via AudioContext to PCM then encodes valid WAV chunks. Native parses the M4A (MP4) container to find sample boundaries and builds valid M4A files for each chunk with proper atoms. https://claude.ai/code/session_01AALsZMcA3oGi5w3xEcR71C --- .../processing.transcribeRecording.test.ts | 110 ++--- services/audio-splitter.ts | 456 ++++++++++++++++++ services/processing.ts | 40 +- 3 files changed, 503 insertions(+), 103 deletions(-) create mode 100644 services/audio-splitter.ts diff --git a/__tests__/processing.transcribeRecording.test.ts b/__tests__/processing.transcribeRecording.test.ts index 4f54982..264a047 100644 --- a/__tests__/processing.transcribeRecording.test.ts +++ b/__tests__/processing.transcribeRecording.test.ts @@ -5,6 +5,7 @@ * - supabase.auth → authenticated session * - fetch → fake Whisper endpoint returning "chunk-" * - expo-file-system/legacy → in-memory file store + * - audio-splitter → returns pre-built chunks (web blobs / native temp URIs) * - react-native Platform → overridable per describe block */ @@ -33,39 +34,32 @@ jest.mock('expo-file-system/legacy', () => ({ copyAsync: jest.fn(), })); +jest.mock('../services/audio-splitter', () => ({ + splitAudioWeb: jest.fn(), + splitAudioNative: jest.fn(), +})); + // ─── Imports (after mocks) ──────────────────────────────────────────────────── import * as FileSystem from 'expo-file-system/legacy'; import { Platform } from 'react-native'; import { transcribeRecording } from '../services/processing'; +import { splitAudioWeb, splitAudioNative } from '../services/audio-splitter'; // ─── Constants ──────────────────────────────────────────────────────────────── const MB = 1024 * 1024; -const CHUNK_BYTES = 24 * MB; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -/** Build a base64 string that represents `bytes` bytes of binary data. */ -function makeBase64(bytes: number): string { - return 'A'.repeat(Math.ceil(bytes / 3) * 4); -} - -// ─── In-memory file store ───────────────────────────────────────────────────── - -const fileStore: Record = {}; -const deletedUris: string[] = []; // ─── Global fetch mock ──────────────────────────────────────────────────────── let whisperCallCount = 0; const mockFetch = jest.fn(); +const deletedUris: string[] = []; beforeEach(() => { jest.clearAllMocks(); whisperCallCount = 0; deletedUris.length = 0; - Object.keys(fileStore).forEach(k => delete fileStore[k]); // Default: native (Platform as any).OS = 'native'; @@ -81,17 +75,9 @@ beforeEach(() => { }); global.fetch = mockFetch as any; - // FileSystem: in-memory file store - (FileSystem.writeAsStringAsync as jest.Mock).mockImplementation((uri: string, data: string) => { - fileStore[uri] = data; - return Promise.resolve(); - }); - (FileSystem.readAsStringAsync as jest.Mock).mockImplementation((uri: string) => - Promise.resolve(fileStore[uri] ?? '') - ); + // FileSystem: track deletions (FileSystem.deleteAsync as jest.Mock).mockImplementation((uri: string) => { deletedUris.push(uri); - delete fileStore[uri]; return Promise.resolve(); }); }); @@ -105,23 +91,26 @@ describe('transcribeRecording — small file (≤ 25 MB)', () => { const result = await transcribeRecording('file://recording.m4a'); expect(mockFetch).toHaveBeenCalledTimes(1); - expect(result).toBe('chunk-1'); + expect(result).toEqual({ + transcription: 'chunk-1', + uncertainTerms: [], + }); }); }); describe('transcribeRecording — large file (> 25 MB, native)', () => { - // 50 MB → ceil(50/24) = 3 chunks (24 MB + 24 MB + 2 MB) - const FILE_SIZE = 50 * MB; - const EXPECTED_CHUNKS = Math.ceil(FILE_SIZE / CHUNK_BYTES); // 3 + const EXPECTED_CHUNKS = 3; + const tempUris = ['cache://chunk-0.m4a', 'cache://chunk-1.m4a', 'cache://chunk-2.m4a']; beforeEach(() => { - (FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true, size: FILE_SIZE }); - fileStore['file://recording.m4a'] = makeBase64(FILE_SIZE); + (FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true, size: 50 * MB }); + (splitAudioNative as jest.Mock).mockResolvedValue(tempUris); }); - it('splits into the correct number of chunks', async () => { + it('calls splitAudioNative and sends one request per chunk', async () => { await transcribeRecording('file://recording.m4a'); + expect(splitAudioNative).toHaveBeenCalledWith('file://recording.m4a'); expect(mockFetch).toHaveBeenCalledTimes(EXPECTED_CHUNKS); }); @@ -129,23 +118,20 @@ describe('transcribeRecording — large file (> 25 MB, native)', () => { const result = await transcribeRecording('file://recording.m4a'); const expected = Array.from({ length: EXPECTED_CHUNKS }, (_, i) => `chunk-${i + 1}`).join(' '); - expect(result).toBe(expected); + expect(result).toEqual({ + transcription: expected, + uncertainTerms: [], + }); }); - it('writes one temp file per chunk then deletes all of them', async () => { + it('deletes all temp files after transcription', async () => { await transcribeRecording('file://recording.m4a'); - expect(FileSystem.writeAsStringAsync).toHaveBeenCalledTimes(EXPECTED_CHUNKS); expect(FileSystem.deleteAsync).toHaveBeenCalledTimes(EXPECTED_CHUNKS); - - const writtenUris = (FileSystem.writeAsStringAsync as jest.Mock).mock.calls.map( - (c: any[]) => c[0] as string - ); - expect(deletedUris.sort()).toEqual(writtenUris.sort()); + expect(deletedUris.sort()).toEqual(tempUris.sort()); }); it('deletes all temp files even when Whisper throws', async () => { - // Make the second Whisper call fail let calls = 0; mockFetch.mockImplementation(() => { calls += 1; @@ -155,47 +141,29 @@ describe('transcribeRecording — large file (> 25 MB, native)', () => { await expect(transcribeRecording('file://recording.m4a')).rejects.toThrow(); - // Cleanup must still have run for all written temp files - const writtenUris = (FileSystem.writeAsStringAsync as jest.Mock).mock.calls.map( - (c: any[]) => c[0] as string - ); - expect(writtenUris.length).toBeGreaterThan(0); - expect(deletedUris.sort()).toEqual(writtenUris.sort()); - }); - - it('each chunk is the correct base64 size', async () => { - await transcribeRecording('file://recording.m4a'); - - const charsPerChunk = (CHUNK_BYTES / 3) * 4; - const chunkSizes = (FileSystem.writeAsStringAsync as jest.Mock).mock.calls.map( - (c: any[]) => (c[1] as string).length - ); - - // All chunks except the last must be exactly charsPerChunk - chunkSizes.slice(0, -1).forEach(len => expect(len).toBe(charsPerChunk)); - // Last chunk is smaller (the remainder) - expect(chunkSizes[chunkSizes.length - 1]).toBeLessThan(charsPerChunk); - // All chunks together re-assemble to the full file - const totalChars = chunkSizes.reduce((a, b) => a + b, 0); - expect(totalChars).toBe(makeBase64(FILE_SIZE).length); + // Cleanup must still have run for all temp files + expect(deletedUris.sort()).toEqual(tempUris.sort()); }); }); describe('transcribeRecording — large file (> 25 MB, web)', () => { - // 30 MB → ceil(30/24) = 2 chunks - const FILE_SIZE = 30 * MB; - const EXPECTED_CHUNKS = Math.ceil(FILE_SIZE / CHUNK_BYTES); // 2 + const EXPECTED_CHUNKS = 2; + const fakeBlobs = [ + new Blob(['chunk-a'], { type: 'audio/wav' }), + new Blob(['chunk-b'], { type: 'audio/wav' }), + ]; beforeEach(() => { (Platform as any).OS = 'web'; + (splitAudioWeb as jest.Mock).mockResolvedValue(fakeBlobs); - const blob = new Blob(['x'.repeat(FILE_SIZE)], { type: 'audio/webm' }); + const originalBlob = new Blob(['x'.repeat(30 * MB)], { type: 'audio/webm' }); let calls = 0; mockFetch.mockImplementation(() => { calls += 1; if (calls === 1) { // First call is fetch(localUri) to get the blob - return Promise.resolve({ blob: () => Promise.resolve(blob) }); + return Promise.resolve({ blob: () => Promise.resolve(originalBlob) }); } const idx = calls - 1; // Whisper call index return Promise.resolve({ @@ -205,9 +173,10 @@ describe('transcribeRecording — large file (> 25 MB, web)', () => { }); }); - it('splits into the correct number of Whisper calls', async () => { + it('calls splitAudioWeb and sends one Whisper request per chunk', async () => { await transcribeRecording('blob:recording'); + expect(splitAudioWeb).toHaveBeenCalled(); // 1 (blob fetch) + EXPECTED_CHUNKS (Whisper) expect(mockFetch).toHaveBeenCalledTimes(1 + EXPECTED_CHUNKS); }); @@ -216,7 +185,10 @@ describe('transcribeRecording — large file (> 25 MB, web)', () => { const result = await transcribeRecording('blob:recording'); const expected = Array.from({ length: EXPECTED_CHUNKS }, (_, i) => `chunk-${i + 1}`).join(' '); - expect(result).toBe(expected); + expect(result).toEqual({ + transcription: expected, + uncertainTerms: [], + }); }); it('does not touch the native filesystem', async () => { diff --git a/services/audio-splitter.ts b/services/audio-splitter.ts new file mode 100644 index 0000000..1def0d6 --- /dev/null +++ b/services/audio-splitter.ts @@ -0,0 +1,456 @@ +/** + * Audio file splitter for files exceeding the 25 MB Whisper API limit. + * + * Web: AudioContext → decode to PCM → split → encode as WAV chunks + * Native: Parse M4A (MP4) container → split at sample boundaries → valid M4A chunks + */ + +import { Platform } from 'react-native'; +import * as FileSystem from 'expo-file-system/legacy'; + +const CHUNK_BYTES = 24 * 1024 * 1024; // 24 MB per chunk (1 MB safety margin) + +// ─── Shared helpers ────────────────────────────────────────────────────────── + +function concatBytes(...arrays: Uint8Array[]): Uint8Array { + const total = arrays.reduce((s, a) => s + a.length, 0); + const result = new Uint8Array(total); + let off = 0; + for (const a of arrays) { + result.set(a, off); + off += a.length; + } + return result; +} + +// ─── Web: AudioContext → WAV ───────────────────────────────────────────────── + +function encodeWav(pcm: Float32Array, sampleRate: number): Blob { + const n = pcm.length; + const buf = new ArrayBuffer(44 + n * 2); + const v = new DataView(buf); + const w = (off: number, s: string) => { + for (let i = 0; i < s.length; i++) v.setUint8(off + i, s.charCodeAt(i)); + }; + + w(0, 'RIFF'); + v.setUint32(4, 36 + n * 2, true); + w(8, 'WAVE'); + w(12, 'fmt '); + v.setUint32(16, 16, true); // fmt chunk size + v.setUint16(20, 1, true); // PCM + v.setUint16(22, 1, true); // mono + v.setUint32(24, sampleRate, true); // sample rate + v.setUint32(28, sampleRate * 2, true); // byte rate (mono, 16-bit) + v.setUint16(32, 2, true); // block align + v.setUint16(34, 16, true); // bits per sample + w(36, 'data'); + v.setUint32(40, n * 2, true); + + for (let i = 0; i < n; i++) { + const s = Math.max(-1, Math.min(1, pcm[i])); + v.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true); + } + + return new Blob([buf], { type: 'audio/wav' }); +} + +export async function splitAudioWeb(blob: Blob): Promise { + const arrayBuffer = await blob.arrayBuffer(); + const ctx = new (globalThis.AudioContext || (globalThis as any).webkitAudioContext)(); + try { + const audioBuffer = await ctx.decodeAudioData(arrayBuffer); + const sr = audioBuffer.sampleRate; + const len = audioBuffer.length; + const nCh = audioBuffer.numberOfChannels; + + // Mix to mono + const mono = new Float32Array(len); + for (let ch = 0; ch < nCh; ch++) { + const chData = audioBuffer.getChannelData(ch); + for (let i = 0; i < len; i++) mono[i] += chData[i] / nCh; + } + + // Max samples per chunk: (CHUNK_BYTES - 44 byte WAV header) / 2 bytes per sample + const samplesPerChunk = Math.floor((CHUNK_BYTES - 44) / 2); + const blobs: Blob[] = []; + for (let off = 0; off < len; off += samplesPerChunk) { + blobs.push(encodeWav(mono.slice(off, Math.min(off + samplesPerChunk, len)), sr)); + } + return blobs; + } finally { + await ctx.close(); + } +} + +// ─── Native: M4A (MP4) parse & mux ────────────────────────────────────────── + +interface M4AInfo { + ftyp: Uint8Array; + timescale: number; + stsd: Uint8Array; // complete stsd atom (with header) + sampleSizes: number[]; + sampleDeltas: { count: number; delta: number }[]; + audioDataOffset: number; // file-level offset of first audio sample +} + +const CONTAINERS = new Set(['moov', 'trak', 'mdia', 'minf', 'stbl', 'dinf', 'udta']); + +function readType(d: Uint8Array, pos: number): string { + return String.fromCharCode(d[pos], d[pos + 1], d[pos + 2], d[pos + 3]); +} + +function parseM4A(data: Uint8Array): M4AInfo { + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const info: Partial = {}; + + const walk = (start: number, end: number) => { + let pos = start; + while (pos + 8 <= end) { + let size = view.getUint32(pos); + const type = readType(data, pos + 4); + let headerSize = 8; + + if (size === 1 && pos + 16 <= end) { + // 64-bit extended size + const hi = view.getUint32(pos + 8); + const lo = view.getUint32(pos + 12); + size = hi * 0x100000000 + lo; + headerSize = 16; + } else if (size === 0) { + size = end - pos; // atom extends to end of parent + } + if (size < headerSize || pos + size > end) break; + + const dataStart = pos + headerSize; + const atomEnd = pos + size; + + switch (type) { + case 'ftyp': + info.ftyp = data.slice(pos, atomEnd); + break; + + case 'mdhd': { + // version(1)+flags(3) then fields differ by version + const ver = data[dataStart]; + // v0: create(4)+mod(4)+timescale(4) → timescale at +12 + // v1: create(8)+mod(8)+timescale(4) → timescale at +20 + const tsOff = ver === 0 ? 12 : 20; + info.timescale = view.getUint32(dataStart + tsOff); + break; + } + + case 'stsd': + info.stsd = data.slice(pos, atomEnd); + break; + + case 'stsz': { + // version+flags(4), sample_size(4), sample_count(4), entries… + const constantSize = view.getUint32(dataStart + 4); + const count = view.getUint32(dataStart + 8); + const sizes: number[] = []; + if (constantSize === 0) { + for (let i = 0; i < count; i++) sizes.push(view.getUint32(dataStart + 12 + i * 4)); + } else { + for (let i = 0; i < count; i++) sizes.push(constantSize); + } + info.sampleSizes = sizes; + break; + } + + case 'stts': { + // version+flags(4), entry_count(4), entries(8 each) + const entryCount = view.getUint32(dataStart + 4); + const deltas: { count: number; delta: number }[] = []; + for (let i = 0; i < entryCount; i++) { + deltas.push({ + count: view.getUint32(dataStart + 8 + i * 8), + delta: view.getUint32(dataStart + 12 + i * 8), + }); + } + info.sampleDeltas = deltas; + break; + } + + case 'stco': { + // version+flags(4), entry_count(4), offsets(4 each) + const n = view.getUint32(dataStart + 4); + if (n > 0) info.audioDataOffset = view.getUint32(dataStart + 8); + break; + } + + case 'co64': { + const n = view.getUint32(dataStart + 4); + if (n > 0) { + const hi = view.getUint32(dataStart + 8); + const lo = view.getUint32(dataStart + 12); + info.audioDataOffset = hi * 0x100000000 + lo; + } + break; + } + + default: + if (CONTAINERS.has(type)) walk(dataStart, atomEnd); + break; + } + + pos = atomEnd; + } + }; + + walk(0, data.length); + + if (!info.ftyp) throw new Error('Invalid M4A: missing ftyp atom'); + if (info.timescale == null) throw new Error('Invalid M4A: missing mdhd atom'); + if (!info.stsd) throw new Error('Invalid M4A: missing stsd atom'); + if (!info.sampleSizes) throw new Error('Invalid M4A: missing stsz atom'); + if (!info.sampleDeltas) throw new Error('Invalid M4A: missing stts atom'); + if (info.audioDataOffset == null) throw new Error('Invalid M4A: missing stco/co64 atom'); + + return info as M4AInfo; +} + +// ─── M4A muxer helpers ────────────────────────────────────────────────────── + +function buildAtom(type: string, payload: Uint8Array): Uint8Array { + const atom = new Uint8Array(8 + payload.length); + new DataView(atom.buffer).setUint32(0, 8 + payload.length); + atom[4] = type.charCodeAt(0); + atom[5] = type.charCodeAt(1); + atom[6] = type.charCodeAt(2); + atom[7] = type.charCodeAt(3); + atom.set(payload, 8); + return atom; +} + +/** Extract stts entries for samples [startSample, endSample). */ +function sttsForRange( + allDeltas: { count: number; delta: number }[], + startSample: number, + endSample: number, +): { count: number; delta: number }[] { + const result: { count: number; delta: number }[] = []; + let idx = 0; + for (const { count, delta } of allDeltas) { + const entryEnd = idx + count; + if (entryEnd <= startSample) { idx = entryEnd; continue; } + if (idx >= endSample) break; + const n = Math.min(entryEnd, endSample) - Math.max(idx, startSample); + if (n > 0) { + if (result.length > 0 && result[result.length - 1].delta === delta) { + result[result.length - 1].count += n; + } else { + result.push({ count: n, delta }); + } + } + idx = entryEnd; + } + return result; +} + +/** + * Build a minimal valid M4A file containing the given audio samples. + * + * Atom layout: ftyp | moov (mvhd, trak(tkhd, mdia(mdhd, hdlr, minf(smhd, dinf, stbl)))) | mdat + */ +function buildM4AFile( + ftyp: Uint8Array, + stsd: Uint8Array, + timescale: number, + sampleSizes: number[], + sttsEntries: { count: number; delta: number }[], + audioData: Uint8Array, +): Uint8Array { + const n = sampleSizes.length; + const duration = sttsEntries.reduce((s, e) => s + e.count * e.delta, 0); + + // ── stbl children ── + + // stts: version+flags(4) + entry_count(4) + entries(8 each) + const sttsP = new Uint8Array(4 + 4 + sttsEntries.length * 8); + const sttsV = new DataView(sttsP.buffer); + sttsV.setUint32(4, sttsEntries.length); + sttsEntries.forEach((e, i) => { + sttsV.setUint32(8 + i * 8, e.count); + sttsV.setUint32(12 + i * 8, e.delta); + }); + + // stsz: version+flags(4) + sample_size(4) + count(4) + entries(4 each) + const stszP = new Uint8Array(4 + 4 + 4 + n * 4); + const stszV = new DataView(stszP.buffer); + stszV.setUint32(8, n); + sampleSizes.forEach((sz, i) => stszV.setUint32(12 + i * 4, sz)); + + // stsc: version+flags(4) + entry_count(4) + one entry(12) + const stscP = new Uint8Array(4 + 4 + 12); + const stscV = new DataView(stscP.buffer); + stscV.setUint32(4, 1); + stscV.setUint32(8, 1); // first_chunk (1-based) + stscV.setUint32(12, n); // samples_per_chunk + stscV.setUint32(16, 1); // sample_description_index + + // stco: version+flags(4) + entry_count(4) + offset(4) — placeholder, patched later + const stcoP = new Uint8Array(4 + 4 + 4); + new DataView(stcoP.buffer).setUint32(4, 1); + + const stbl = buildAtom('stbl', concatBytes( + stsd, + buildAtom('stts', sttsP), + buildAtom('stsz', stszP), + buildAtom('stsc', stscP), + buildAtom('stco', stcoP), + )); + + // ── minf ── + + const smhd = buildAtom('smhd', new Uint8Array(8)); // version+flags(4) + balance(2) + reserved(2) + + const urlP = new Uint8Array(4); + urlP[3] = 1; // self-contained flag + const drefP = new Uint8Array(8 + 12); // version+flags(4) + count(4) + url atom(12) + new DataView(drefP.buffer).setUint32(4, 1); + drefP.set(buildAtom('url ', urlP), 8); + + const minf = buildAtom('minf', concatBytes( + smhd, + buildAtom('dinf', buildAtom('dref', drefP)), + stbl, + )); + + // ── mdia ── + + // mdhd v0: version+flags(4) + create(4) + mod(4) + timescale(4) + duration(4) + lang(2) + pre(2) = 24 + const mdhdP = new Uint8Array(24); + const mdhdV = new DataView(mdhdP.buffer); + mdhdV.setUint32(12, timescale); + mdhdV.setUint32(16, duration); + mdhdP[20] = 0x55; mdhdP[21] = 0xC4; // undetermined language + + // hdlr: version+flags(4) + pre_defined(4) + handler_type(4) + reserved(12) + name + const handlerName = new TextEncoder().encode('SoundHandler\0'); + const hdlrP = new Uint8Array(4 + 4 + 4 + 12 + handlerName.length); + // handler_type = 'soun' at offset 8 + hdlrP[8] = 0x73; hdlrP[9] = 0x6F; hdlrP[10] = 0x75; hdlrP[11] = 0x6E; + hdlrP.set(handlerName, 24); + + const mdia = buildAtom('mdia', concatBytes( + buildAtom('mdhd', mdhdP), + buildAtom('hdlr', hdlrP), + minf, + )); + + // ── trak ── + + // tkhd v0: version+flags(4) + create(4) + mod(4) + trackID(4) + reserved(4) + // + duration(4) + reserved(8) + layer(2) + altGroup(2) + volume(2) + // + reserved(2) + matrix(36) + width(4) + height(4) = 84 + const tkhdP = new Uint8Array(84); + const tkhdV = new DataView(tkhdP.buffer); + tkhdP[3] = 3; // flags: enabled + in_movie + tkhdV.setUint32(12, 1); // track_ID + tkhdV.setUint32(20, duration); // duration + tkhdV.setUint16(36, 0x0100); // volume = 1.0 + tkhdV.setUint32(40, 0x00010000); // matrix[0] + tkhdV.setUint32(56, 0x00010000); // matrix[4] + tkhdV.setUint32(72, 0x40000000); // matrix[8] + + const trak = buildAtom('trak', concatBytes(buildAtom('tkhd', tkhdP), mdia)); + + // ── moov ── + + // mvhd v0: version+flags(4) + create(4) + mod(4) + timescale(4) + duration(4) + // + rate(4) + volume(2) + reserved(10) + matrix(36) + pre_defined(24) + // + next_track_id(4) = 100 + const mvhdP = new Uint8Array(100); + const mvhdV = new DataView(mvhdP.buffer); + mvhdV.setUint32(12, timescale); // timescale + mvhdV.setUint32(16, duration); // duration + mvhdV.setUint32(20, 0x00010000); // rate = 1.0 + mvhdV.setUint16(24, 0x0100); // volume = 1.0 + mvhdV.setUint32(36, 0x00010000); // matrix[0] + mvhdV.setUint32(52, 0x00010000); // matrix[4] + mvhdV.setUint32(68, 0x40000000); // matrix[8] + mvhdV.setUint32(96, 2); // next_track_id + + const moov = buildAtom('moov', concatBytes(buildAtom('mvhd', mvhdP), trak)); + + // ── mdat ── + const mdat = buildAtom('mdat', audioData); + + // ── Patch stco offset ── + // Audio data starts at: ftyp.length + moov.length + 8 (mdat atom header) + const audioOffset = ftyp.length + moov.length + 8; + // Find 'stco' atom in moov and write the offset + for (let i = 0; i < moov.length - 20; i++) { + if (moov[i + 4] === 0x73 && moov[i + 5] === 0x74 && + moov[i + 6] === 0x63 && moov[i + 7] === 0x6F) { // 'stco' + new DataView(moov.buffer, moov.byteOffset).setUint32(i + 16, audioOffset); + break; + } + } + + return concatBytes(ftyp, moov, mdat); +} + +// ─── Native: split M4A into valid chunk files ──────────────────────────────── + +function base64ToBytes(b64: string): Uint8Array { + const raw = atob(b64); + const bytes = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); + return bytes; +} + +function bytesToBase64(bytes: Uint8Array): string { + const parts: string[] = []; + for (let i = 0; i < bytes.length; i += 8192) { + const slice = bytes.subarray(i, Math.min(i + 8192, bytes.length)); + parts.push(String.fromCharCode.apply(null, Array.from(slice))); + } + return btoa(parts.join('')); +} + +export async function splitAudioNative(localUri: string): Promise { + const base64 = await FileSystem.readAsStringAsync(localUri, { + encoding: FileSystem.EncodingType.Base64, + }); + const data = base64ToBytes(base64); + const info = parseM4A(data); + + // Group samples into chunks of ≤ CHUNK_BYTES of audio data + const groups: { start: number; end: number }[] = []; + let grpStart = 0; + let grpSize = 0; + for (let i = 0; i < info.sampleSizes.length; i++) { + if (grpSize + info.sampleSizes[i] > CHUNK_BYTES && grpSize > 0) { + groups.push({ start: grpStart, end: i }); + grpStart = i; + grpSize = 0; + } + grpSize += info.sampleSizes[i]; + } + if (grpSize > 0) groups.push({ start: grpStart, end: info.sampleSizes.length }); + + // Build a valid M4A file for each group and write to a temp file + const tempUris: string[] = []; + let dataOffset = info.audioDataOffset; + + for (let g = 0; g < groups.length; g++) { + const { start, end } = groups[g]; + const chunkSizes = info.sampleSizes.slice(start, end); + const chunkDataSize = chunkSizes.reduce((s, sz) => s + sz, 0); + const audioBytes = data.slice(dataOffset, dataOffset + chunkDataSize); + dataOffset += chunkDataSize; + + const entries = sttsForRange(info.sampleDeltas, start, end); + const m4aBytes = buildM4AFile(info.ftyp, info.stsd, info.timescale, chunkSizes, entries, audioBytes); + + const tempUri = `${FileSystem.cacheDirectory}chunk-${Date.now()}-${g}.m4a`; + await FileSystem.writeAsStringAsync(tempUri, bytesToBase64(m4aBytes), { + encoding: FileSystem.EncodingType.Base64, + }); + tempUris.push(tempUri); + } + + return tempUris; +} diff --git a/services/processing.ts b/services/processing.ts index 4388f9b..c88f66d 100644 --- a/services/processing.ts +++ b/services/processing.ts @@ -2,6 +2,7 @@ import { Platform } from 'react-native'; import * as FileSystem from 'expo-file-system/legacy'; import { supabase } from './supabase'; import { ProcessingResult, FormatType, UncertainTerm } from '../types'; +import { splitAudioWeb, splitAudioNative } from './audio-splitter'; export interface TranscribeResult { transcription: string; @@ -9,17 +10,6 @@ export interface TranscribeResult { } const WHISPER_MAX_BYTES = 25 * 1024 * 1024; // 25 MB — OpenAI Whisper API limit -const CHUNK_BYTES = 24 * 1024 * 1024; // 24 MB per chunk (1 MB safety margin) - -function sliceBlob(blob: Blob): Blob[] { - const chunks: Blob[] = []; - let offset = 0; - while (offset < blob.size) { - chunks.push(blob.slice(offset, offset + CHUNK_BYTES, blob.type)); - offset += CHUNK_BYTES; - } - return chunks; -} export const transcribeRecording = async ( localUri: string, @@ -73,13 +63,13 @@ export const transcribeRecording = async ( return sendChunk(formData); } - // File exceeds 25 MB: split, transcribe each chunk, join results + // File exceeds 25 MB: decode audio, split into valid WAV chunks, transcribe each // Uncertain terms from fragment chunks are meaningless — skip them - const chunks = sliceBlob(blob); + const chunks = await splitAudioWeb(blob); const parts: string[] = []; for (let i = 0; i < chunks.length; i++) { const formData = new FormData(); - formData.append('audio', chunks[i] as any, `recording-part${i + 1}.webm`); + formData.append('audio', chunks[i] as any, `recording-part${i + 1}.wav`); formData.append('mode', 'transcribe_only'); const { transcription } = await sendChunk(formData); parts.push(transcription); @@ -99,26 +89,8 @@ export const transcribeRecording = async ( return sendChunk(formData); } - // File exceeds 25 MB: read as base64, split into chunks, write temp files - // Base64 chars per chunk: (CHUNK_BYTES / 3) * 4 — always a multiple of 4 for valid base64 - const charsPerChunk = (CHUNK_BYTES / 3) * 4; - const base64Full = await FileSystem.readAsStringAsync(localUri, { - encoding: FileSystem.EncodingType.Base64, - }); - - const tempUris: string[] = []; - let offset = 0; - let i = 0; - while (offset < base64Full.length) { - const chunkB64 = base64Full.slice(offset, offset + charsPerChunk); - const tempUri = `${FileSystem.cacheDirectory}chunk-${Date.now()}-${i}.m4a`; - await FileSystem.writeAsStringAsync(tempUri, chunkB64, { - encoding: FileSystem.EncodingType.Base64, - }); - tempUris.push(tempUri); - offset += charsPerChunk; - i++; - } + // File exceeds 25 MB: parse M4A container, split at sample boundaries into valid M4A chunks + const tempUris = await splitAudioNative(localUri); try { const parts: string[] = [];