refactor(app): replace voice hooks with voice runtime and audio engine architecture

This commit is contained in:
Mohamed Boudra
2026-03-13 20:53:55 +07:00
parent 730f34753a
commit 80a2f8052c
51 changed files with 4308 additions and 2380 deletions
+88
View File
@@ -0,0 +1,88 @@
export interface Pcm16Wav {
sampleRate: number;
samples: Int16Array;
}
export function parsePcm16Wav(buffer: ArrayBuffer): Pcm16Wav | null {
if (buffer.byteLength < 44) {
return null;
}
const view = new DataView(buffer);
function readAscii(offset: number, length: number): string {
let out = "";
for (let i = 0; i < length; i += 1) {
out += String.fromCharCode(view.getUint8(offset + i));
}
return out;
}
if (readAscii(0, 4) !== "RIFF" || readAscii(8, 4) !== "WAVE") {
return null;
}
let offset = 12;
let channels = 0;
let sampleRate = 0;
let bitsPerSample = 0;
let dataOffset = 0;
let dataSize = 0;
while (offset + 8 <= buffer.byteLength) {
const chunkId = readAscii(offset, 4);
const chunkSize = view.getUint32(offset + 4, true);
const chunkDataOffset = offset + 8;
if (chunkDataOffset + chunkSize > buffer.byteLength) {
return null;
}
if (chunkId === "fmt " && chunkSize >= 16) {
const audioFormat = view.getUint16(chunkDataOffset, true);
channels = view.getUint16(chunkDataOffset + 2, true);
sampleRate = view.getUint32(chunkDataOffset + 4, true);
bitsPerSample = view.getUint16(chunkDataOffset + 14, true);
if (audioFormat !== 1) {
return null;
}
}
if (chunkId === "data") {
dataOffset = chunkDataOffset;
dataSize = chunkSize;
break;
}
offset = chunkDataOffset + chunkSize + (chunkSize % 2);
}
if (!dataOffset || dataSize <= 0 || sampleRate <= 0 || bitsPerSample !== 16 || channels <= 0) {
return null;
}
const sampleCount = Math.floor(dataSize / 2);
const interleaved = new Int16Array(buffer, dataOffset, sampleCount);
if (channels === 1) {
return {
sampleRate,
samples: new Int16Array(interleaved),
};
}
const frameCount = Math.floor(interleaved.length / channels);
const mono = new Int16Array(frameCount);
for (let frame = 0; frame < frameCount; frame += 1) {
let sum = 0;
for (let channel = 0; channel < channels; channel += 1) {
sum += interleaved[frame * channels + channel] ?? 0;
}
mono[frame] = Math.round(sum / channels);
}
return {
sampleRate,
samples: mono,
};
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { parsePcm16Wav } from "@/utils/pcm16-wav";
interface BuildWavInput {
channels?: number;
sampleRate?: number;
samples: number[];
}
function buildPcm16Wav(input: BuildWavInput): ArrayBuffer {
const channels = input.channels ?? 1;
const sampleRate = input.sampleRate ?? 24000;
const bytesPerSample = 2;
const dataSize = input.samples.length * bytesPerSample;
const buffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(buffer);
function writeAscii(offset: number, value: string): void {
for (let i = 0; i < value.length; i += 1) {
view.setUint8(offset + i, value.charCodeAt(i));
}
}
writeAscii(0, "RIFF");
view.setUint32(4, 36 + dataSize, true);
writeAscii(8, "WAVE");
writeAscii(12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * channels * bytesPerSample, true);
view.setUint16(32, channels * bytesPerSample, true);
view.setUint16(34, 16, true);
writeAscii(36, "data");
view.setUint32(40, dataSize, true);
input.samples.forEach((sample, index) => {
view.setInt16(44 + index * bytesPerSample, sample, true);
});
return buffer;
}
describe("parsePcm16Wav", () => {
it("parses mono PCM16 wav data", () => {
const buffer = buildPcm16Wav({
sampleRate: 16000,
samples: [100, -200, 300],
});
const parsed = parsePcm16Wav(buffer);
expect(parsed).not.toBeNull();
expect(parsed?.sampleRate).toBe(16000);
expect(Array.from(parsed?.samples ?? [])).toEqual([100, -200, 300]);
});
it("downmixes multichannel PCM16 wav data to mono", () => {
const buffer = buildPcm16Wav({
channels: 2,
samples: [1000, -1000, 3000, 1000],
});
const parsed = parsePcm16Wav(buffer);
expect(parsed).not.toBeNull();
expect(Array.from(parsed?.samples ?? [])).toEqual([0, 2000]);
});
it("rejects non-wav payloads", () => {
const buffer = new TextEncoder().encode("not-a-wav").buffer;
expect(parsePcm16Wav(buffer)).toBeNull();
});
});
+33
View File
@@ -0,0 +1,33 @@
import { Asset } from "expo-asset";
import { File } from "expo-file-system";
import { Platform } from "react-native";
export { parsePcm16Wav, type Pcm16Wav } from "@/utils/pcm16-wav";
export const THINKING_TONE_REPEAT_GAP_MS = 350;
let thinkingToneArrayBufferPromise: Promise<ArrayBuffer> | null = null;
async function readThinkingToneArrayBuffer(): Promise<ArrayBuffer> {
const toneModule = require("../../assets/audio/thinking-tone.wav");
const asset = Asset.fromModule(toneModule);
if (Platform.OS === "web") {
const response = await fetch(asset.uri);
if (!response.ok) {
throw new Error(`Failed to fetch thinking tone asset: ${response.status}`);
}
return await response.arrayBuffer();
}
const resolvedAsset = asset.localUri ? asset : await asset.downloadAsync();
const fileUri = resolvedAsset.localUri ?? resolvedAsset.uri;
const file = new File(fileUri);
return await file.arrayBuffer();
}
export async function loadThinkingToneArrayBuffer(): Promise<ArrayBuffer> {
if (!thinkingToneArrayBufferPromise) {
thinkingToneArrayBufferPromise = readThinkingToneArrayBuffer();
}
return await thinkingToneArrayBufferPromise;
}
@@ -140,4 +140,38 @@ describe("tool-call-display", () => {
expect(display.errorText).toBe('{\n "message": "boom"\n}');
});
it("shows terminal interaction with only the fixed label when no command is available", () => {
const display = buildToolCallDisplayModel({
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
icon: "square_terminal",
},
});
expect(display).toEqual({
displayName: "Interacted with terminal",
});
});
it("shows terminal interaction command as the summary when available", () => {
const display = buildToolCallDisplayModel({
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
label: "npm run test",
icon: "square_terminal",
},
});
expect(display).toEqual({
displayName: "Interacted with terminal",
summary: "npm run test",
});
});
});