refactor(app): replace voice hooks with voice runtime and audio engine architecture
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user