|
| 1 | +import { expect, test, vi } from "vitest"; |
| 2 | +import { AudioSessionRecorder, type AudioFile } from "./audio-session-recorder"; |
| 3 | + |
| 4 | +const SOURCES = { |
| 5 | + mic: { label: "User audio" }, |
| 6 | + speaker: { label: "Agent audio" }, |
| 7 | +}; |
| 8 | +/** 1 kHz recordings with 100 ms (200 byte stereo) segments; records every file handed off. */ |
| 9 | +function recorder( |
| 10 | + store: (file: AudioFile) => Promise<void> = async () => {}, |
| 11 | + audio = { sampleRate: 1000, segmentBytes: 400 }, |
| 12 | +) { |
| 13 | + const files: AudioFile[] = []; |
| 14 | + const audioSession = new AudioSessionRecorder({ |
| 15 | + origin: 1_790_000_000_000, |
| 16 | + sources: SOURCES, |
| 17 | + audio, |
| 18 | + onFile: async (file) => { |
| 19 | + await store(file); |
| 20 | + files.push(file); |
| 21 | + }, |
| 22 | + }); |
| 23 | + return { audioSession, files }; |
| 24 | +} |
| 25 | +const mono = (offsetMs: number) => ({ |
| 26 | + sampleRate: 1000, |
| 27 | + channelCount: 1, |
| 28 | + offsetMs, |
| 29 | +}); |
| 30 | +const ramp = (length: number) => Int16Array.from({ length }, (_, i) => i + 1); |
| 31 | +/** Every segment entry handed off, merged in order as a session span would. */ |
| 32 | +const segments = (files: AudioFile[]) => |
| 33 | + Object.assign( |
| 34 | + {}, |
| 35 | + ...files.map( |
| 36 | + (file) => file.metadata["audio.segments"] as Record<string, unknown>, |
| 37 | + ), |
| 38 | + ); |
| 39 | + |
| 40 | +test("describes a session without audio: schema, sources and timeline, and no files", async () => { |
| 41 | + const onFile = vi.fn(); |
| 42 | + const audioSession = new AudioSessionRecorder({ |
| 43 | + origin: 5, |
| 44 | + sources: SOURCES, |
| 45 | + onFile, |
| 46 | + }); |
| 47 | + expect(audioSession.session()).toEqual({ |
| 48 | + "audio.schema.version": "3.0.0", |
| 49 | + "audio.sources": SOURCES, |
| 50 | + "audio.timeline": { origin_unix_ms: 5 }, |
| 51 | + }); |
| 52 | + expect(audioSession.record("mic", ramp(10), mono(0))).toBe(false); |
| 53 | + expect(audioSession.active).toBe(false); |
| 54 | + await audioSession.finish(); |
| 55 | + expect(onFile).not.toHaveBeenCalled(); |
| 56 | +}); |
| 57 | + |
| 58 | +test("a participant's entry is returned on first sight only; its ID is stable", () => { |
| 59 | + const { audioSession } = recorder(); |
| 60 | + const caller = { |
| 61 | + type: "user" as const, |
| 62 | + attributes: { "contrib.example.id": "PA_caller" }, |
| 63 | + }; |
| 64 | + expect(audioSession.participant("PA_caller", caller)).toEqual({ |
| 65 | + id: "participant-1", |
| 66 | + added: { "audio.participants": { "participant-1": caller } }, |
| 67 | + }); |
| 68 | + expect(audioSession.participant("PA_agent", { type: "agent" }).added).toEqual( |
| 69 | + { "audio.participants": { "participant-2": { type: "agent" } } }, |
| 70 | + ); |
| 71 | + // Seen again: the same ID, and nothing new to publish. |
| 72 | + expect(audioSession.participant("PA_caller", caller)).toEqual({ |
| 73 | + id: "participant-1", |
| 74 | + }); |
| 75 | +}); |
| 76 | + |
| 77 | +test("speakers are scoped to their source and kept apart from participants", () => { |
| 78 | + const { audioSession } = recorder(); |
| 79 | + const voice = (source: string, nativeId: string) => |
| 80 | + audioSession.speaker(source, nativeId, { type: "user" }); |
| 81 | + expect(voice("mic", "0")).toEqual({ |
| 82 | + id: "speaker-1", |
| 83 | + added: { "audio.speakers": { "speaker-1": { type: "user" } } }, |
| 84 | + }); |
| 85 | + expect(voice("mic", "1").id).toBe("speaker-2"); |
| 86 | + expect(voice("mic", "0")).toEqual({ id: "speaker-1" }); |
| 87 | + // The same label on another source is a different voice. |
| 88 | + expect(voice("speaker", "0").id).toBe("speaker-3"); |
| 89 | + // A participant with the same native ID is a different member. |
| 90 | + expect(audioSession.participant("0", { type: "user" }).id).toBe( |
| 91 | + "participant-1", |
| 92 | + ); |
| 93 | +}); |
| 94 | + |
| 95 | +test("each recorded file is handed off with its ready entry", async () => { |
| 96 | + let release!: () => void; |
| 97 | + const gate = new Promise<void>((resolve) => (release = resolve)); |
| 98 | + const { audioSession, files } = recorder(() => gate); |
| 99 | + audioSession.record("mic", ramp(150), mono(0)); |
| 100 | + audioSession.record("speaker", ramp(150), mono(0)); |
| 101 | + audioSession.advance(150); |
| 102 | + const finishing = audioSession.finish(); |
| 103 | + await new Promise((resolve) => setTimeout(resolve, 5)); |
| 104 | + // Nothing is described before it is stored. |
| 105 | + expect(files).toEqual([]); |
| 106 | + release(); |
| 107 | + await finishing; |
| 108 | + expect(files.map((f) => [f.id, f.mimeType, f.data?.byteLength])).toEqual([ |
| 109 | + ["part-0", "audio/pcm", 400], |
| 110 | + ["part-1", "audio/pcm", 200], |
| 111 | + ]); |
| 112 | + expect(files[0].metadata).toEqual({ |
| 113 | + "audio.segments": { |
| 114 | + "part-0": { |
| 115 | + state: "ready", |
| 116 | + start_offset_ms: 0, |
| 117 | + duration_ms: 100, |
| 118 | + attachment: { ref: "/input/audio/part-0" }, |
| 119 | + mime_type: "audio/pcm", |
| 120 | + encoding: "pcm", |
| 121 | + sample_rate_hz: 1000, |
| 122 | + channels: { "0": { sources: ["mic"] }, "1": { sources: ["speaker"] } }, |
| 123 | + attributes: { |
| 124 | + "pcm.sample_format": "s16le", |
| 125 | + "pcm.channel_layout": "interleaved", |
| 126 | + }, |
| 127 | + }, |
| 128 | + }, |
| 129 | + }); |
| 130 | + // The final partial segment. |
| 131 | + expect(segments(files)["part-1"]).toMatchObject({ |
| 132 | + state: "ready", |
| 133 | + start_offset_ms: 100, |
| 134 | + duration_ms: 50, |
| 135 | + }); |
| 136 | +}); |
| 137 | + |
| 138 | +test("whole-millisecond segments tile without drift", async () => { |
| 139 | + // 24 kHz, 1001 frames per segment: 41.708… ms each. |
| 140 | + const { audioSession, files } = recorder(undefined, { |
| 141 | + sampleRate: 24000, |
| 142 | + segmentBytes: 1001 * 4, |
| 143 | + }); |
| 144 | + audioSession.record("mic", ramp(1001 * 5), { |
| 145 | + sampleRate: 24000, |
| 146 | + channelCount: 1, |
| 147 | + offsetMs: 0, |
| 148 | + }); |
| 149 | + await audioSession.finish(); |
| 150 | + const placed = Object.values(segments(files)) as { |
| 151 | + start_offset_ms: number; |
| 152 | + duration_ms: number; |
| 153 | + }[]; |
| 154 | + expect(placed).toHaveLength(5); |
| 155 | + for (let i = 1; i < placed.length; i++) |
| 156 | + expect(placed[i].start_offset_ms).toBe( |
| 157 | + placed[i - 1].start_offset_ms + placed[i - 1].duration_ms, |
| 158 | + ); |
| 159 | + expect(placed.at(-1)!.start_offset_ms + placed.at(-1)!.duration_ms).toBe( |
| 160 | + Math.round((1001 * 5) / 24), |
| 161 | + ); |
| 162 | +}); |
| 163 | + |
| 164 | +test("a failed store is offered again as omitted and stops new audio; accepted audio still stores", async () => { |
| 165 | + let calls = 0; |
| 166 | + const offered: AudioFile[] = []; |
| 167 | + const audioSession = new AudioSessionRecorder({ |
| 168 | + origin: 0, |
| 169 | + sources: SOURCES, |
| 170 | + audio: { sampleRate: 1000, segmentBytes: 400 }, |
| 171 | + onFile: async (file) => { |
| 172 | + offered.push(file); |
| 173 | + if (file.data && ++calls === 2) throw new Error("offline"); |
| 174 | + }, |
| 175 | + }); |
| 176 | + audioSession.record("mic", ramp(300), mono(0)); |
| 177 | + audioSession.advance(300); |
| 178 | + await audioSession.finish(); |
| 179 | + expect(offered.map((f) => [f.id, f.data !== undefined])).toEqual([ |
| 180 | + ["part-0", true], |
| 181 | + ["part-1", true], |
| 182 | + ["part-1", false], |
| 183 | + ["part-2", true], |
| 184 | + ]); |
| 185 | + expect(offered[2].metadata).toEqual({ |
| 186 | + "audio.segments": { |
| 187 | + "part-1": { |
| 188 | + state: "omitted", |
| 189 | + start_offset_ms: 100, |
| 190 | + duration_ms: 100, |
| 191 | + reason: "recording_export_failed", |
| 192 | + }, |
| 193 | + }, |
| 194 | + }); |
| 195 | + expect(audioSession.reason).toBe("recording_export_failed"); |
| 196 | + expect(audioSession.record("mic", ramp(10), mono(300))).toBe(false); |
| 197 | +}); |
| 198 | + |
| 199 | +test("failing middleware omits the segment with the recorder's reason", async () => { |
| 200 | + vi.spyOn(console, "warn").mockImplementation(() => {}); |
| 201 | + const files: AudioFile[] = []; |
| 202 | + const audioSession = new AudioSessionRecorder({ |
| 203 | + origin: 0, |
| 204 | + sources: SOURCES, |
| 205 | + audio: { |
| 206 | + sampleRate: 1000, |
| 207 | + segmentBytes: 400, |
| 208 | + middleware: [ |
| 209 | + () => { |
| 210 | + throw new Error("encoder unavailable"); |
| 211 | + }, |
| 212 | + ], |
| 213 | + }, |
| 214 | + onFile: async (file) => void files.push(file), |
| 215 | + }); |
| 216 | + audioSession.record("mic", ramp(50), mono(0)); |
| 217 | + await audioSession.finish(); |
| 218 | + expect(files).toEqual([ |
| 219 | + { |
| 220 | + id: "part-0", |
| 221 | + metadata: { |
| 222 | + "audio.segments": { |
| 223 | + "part-0": { |
| 224 | + state: "omitted", |
| 225 | + start_offset_ms: 0, |
| 226 | + duration_ms: 50, |
| 227 | + reason: "recording_transform_failed", |
| 228 | + }, |
| 229 | + }, |
| 230 | + }, |
| 231 | + }, |
| 232 | + ]); |
| 233 | + vi.restoreAllMocks(); |
| 234 | +}); |
| 235 | + |
| 236 | +test("finish is idempotent and later audio is refused", async () => { |
| 237 | + const { audioSession } = recorder(); |
| 238 | + audioSession.record("mic", ramp(10), mono(0)); |
| 239 | + const finishing = audioSession.finish(); |
| 240 | + expect(audioSession.finish()).toBe(finishing); |
| 241 | + await finishing; |
| 242 | + expect(audioSession.record("mic", ramp(10), mono(10))).toBe(false); |
| 243 | +}); |
0 commit comments