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[] = [];