woiceatus commited on
Commit
8b5482f
·
1 Parent(s): 740e55f

fix audio convert

Browse files
public/chatclient/media.js CHANGED
@@ -112,6 +112,10 @@ function classifyFile(file) {
112
  function inferAudioFormat(file) {
113
  const mimeType = file?.type ?? file?.mimeType ?? "";
114
  const name = (file?.name ?? "").toLowerCase();
 
 
 
 
115
  if (mimeType === "audio/wav" || mimeType === "audio/x-wav" || name.endsWith(".wav")) {
116
  return "wav";
117
  }
 
112
  function inferAudioFormat(file) {
113
  const mimeType = file?.type ?? file?.mimeType ?? "";
114
  const name = (file?.name ?? "").toLowerCase();
115
+ if (mimeType === "audio/mp4" || mimeType === "audio/x-m4a" || name.endsWith(".m4a")) {
116
+ return "m4a";
117
+ }
118
+
119
  if (mimeType === "audio/wav" || mimeType === "audio/x-wav" || name.endsWith(".wav")) {
120
  return "wav";
121
  }
public/chatclient/render.js CHANGED
@@ -22,12 +22,18 @@ export function renderAttachments(card, container, summary, attachments, onRemov
22
  export function renderResponse(container, requestPayload, responseBody) {
23
  const assistant = responseBody?.choices?.[0]?.message ?? {};
24
  const text = extractAssistantText(assistant);
 
25
  const images = extractAssistantImages(assistant);
26
  const audioUrl = assistant?.audio?.url || null;
 
27
 
28
  container.innerHTML = "";
29
  container.appendChild(renderInfoCard("Request", describeRequest(requestPayload)));
30
- container.appendChild(renderRichTextCard("Assistant", text || "No assistant text returned."));
 
 
 
 
31
 
32
  for (const imageUrl of images) {
33
  const imageCard = renderInfoCard("Image", imageUrl);
@@ -167,6 +173,18 @@ function extractAssistantImages(message) {
167
  .filter(Boolean);
168
  }
169
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  function renderInfoCard(title, body) {
171
  const card = document.createElement("article");
172
  card.className = "response-block";
 
22
  export function renderResponse(container, requestPayload, responseBody) {
23
  const assistant = responseBody?.choices?.[0]?.message ?? {};
24
  const text = extractAssistantText(assistant);
25
+ const audioTranscript = extractAssistantAudioTranscript(assistant);
26
  const images = extractAssistantImages(assistant);
27
  const audioUrl = assistant?.audio?.url || null;
28
+ const assistantBody = text || audioTranscript || "No assistant text returned.";
29
 
30
  container.innerHTML = "";
31
  container.appendChild(renderInfoCard("Request", describeRequest(requestPayload)));
32
+ container.appendChild(renderRichTextCard("Assistant", assistantBody));
33
+
34
+ if (audioTranscript && !sameText(text, audioTranscript)) {
35
+ container.appendChild(renderRichTextCard("Audio Transcript", audioTranscript));
36
+ }
37
 
38
  for (const imageUrl of images) {
39
  const imageCard = renderInfoCard("Image", imageUrl);
 
173
  .filter(Boolean);
174
  }
175
 
176
+ function extractAssistantAudioTranscript(message) {
177
+ return typeof message?.audio?.transcript === "string" ? message.audio.transcript.trim() : "";
178
+ }
179
+
180
+ function sameText(left, right) {
181
+ return normalizeText(left) === normalizeText(right);
182
+ }
183
+
184
+ function normalizeText(value) {
185
+ return typeof value === "string" ? value.trim().replaceAll(/\s+/g, " ") : "";
186
+ }
187
+
188
  function renderInfoCard(title, body) {
189
  const card = document.createElement("article");
190
  card.className = "response-block";
src/app.js CHANGED
@@ -12,6 +12,20 @@ export function createApp({
12
  const app = express();
13
 
14
  app.disable("x-powered-by");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  app.use(express.json({ limit: jsonLimit }));
16
  app.get("/", (_req, res) => {
17
  res.sendFile(path.join(publicDir, "index.html"));
 
12
  const app = express();
13
 
14
  app.disable("x-powered-by");
15
+ app.use((req, res, next) => {
16
+ res.setHeader("Access-Control-Allow-Origin", "*");
17
+ res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
18
+
19
+ const requestedHeaders = req.get("access-control-request-headers");
20
+ res.setHeader("Access-Control-Allow-Headers", requestedHeaders || "Content-Type, Authorization");
21
+
22
+ if (req.method === "OPTIONS") {
23
+ res.status(204).end();
24
+ return;
25
+ }
26
+
27
+ next();
28
+ });
29
  app.use(express.json({ limit: jsonLimit }));
30
  app.get("/", (_req, res) => {
31
  res.sendFile(path.join(publicDir, "index.html"));
src/services/audioConversionService.js CHANGED
@@ -54,7 +54,8 @@ export function createAudioConversionService({ fetchImpl = fetch, maxAudioDownlo
54
  throw new HttpError(413, `Audio URL exceeded ${maxAudioDownloadMb}MB download limit.`);
55
  }
56
 
57
- return transcodeAudioBuffer(audioBuffer, "mp3");
 
58
  },
59
 
60
  async normalizeBase64Audio({ data, format }) {
@@ -67,8 +68,8 @@ export function createAudioConversionService({ fetchImpl = fetch, maxAudioDownlo
67
  throw new HttpError(413, `Audio input exceeded ${maxAudioDownloadMb}MB upload limit.`);
68
  }
69
 
70
- if (!["mp3", "wav"].includes(format)) {
71
- throw new HttpError(400, "Audio input format must be mp3 or wav.");
72
  }
73
 
74
  try {
@@ -83,19 +84,21 @@ export function createAudioConversionService({ fetchImpl = fetch, maxAudioDownlo
83
  }
84
  };
85
 
86
- async function transcodeAudioBuffer(audioBuffer, outputFormat) {
87
  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "oapix-audio-"));
88
- const inputPath = path.join(tempDir, "input-media");
89
- const outputPath = path.join(tempDir, `output.${outputFormat}`);
 
90
 
91
  try {
 
92
  await fs.writeFile(inputPath, audioBuffer);
93
- await runFfmpeg(inputPath, outputPath, ffmpegOutputArgs(outputFormat));
94
  const convertedBuffer = await fs.readFile(outputPath);
95
 
96
  return {
97
  data: convertedBuffer.toString("base64"),
98
- format: outputFormat
99
  };
100
  } finally {
101
  await fs.rm(tempDir, { force: true, recursive: true });
@@ -103,10 +106,50 @@ export function createAudioConversionService({ fetchImpl = fetch, maxAudioDownlo
103
  }
104
  }
105
 
106
- function ffmpegOutputArgs(outputFormat) {
107
- if (outputFormat === "wav") {
108
- return ["-acodec", "pcm_s16le"];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  }
110
 
111
- return ["-acodec", "libmp3lame"];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  }
 
54
  throw new HttpError(413, `Audio URL exceeded ${maxAudioDownloadMb}MB download limit.`);
55
  }
56
 
57
+ const inputFormat = inferAudioFormatFromUrl(url) || inferAudioFormatFromMimeType(response.headers.get("content-type")) || "unknown";
58
+ return transcodeAudioBuffer(audioBuffer, inputFormat);
59
  },
60
 
61
  async normalizeBase64Audio({ data, format }) {
 
68
  throw new HttpError(413, `Audio input exceeded ${maxAudioDownloadMb}MB upload limit.`);
69
  }
70
 
71
+ if (!["mp3", "wav", "m4a"].includes(format)) {
72
+ throw new HttpError(400, "Audio input format must be mp3, wav, or m4a.");
73
  }
74
 
75
  try {
 
84
  }
85
  };
86
 
87
+ async function transcodeAudioBuffer(audioBuffer, inputFormat) {
88
  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "oapix-audio-"));
89
+ const normalizedInputFormat = normalizeInputFormat(inputFormat);
90
+ const inputPath = path.join(tempDir, `input-media.${normalizedInputFormat === "unknown" ? "bin" : normalizedInputFormat}`);
91
+ const outputPath = path.join(tempDir, "output.mp3");
92
 
93
  try {
94
+ console.log(`convert audio: ${normalizedInputFormat}->mp3`);
95
  await fs.writeFile(inputPath, audioBuffer);
96
+ await runFfmpeg(inputPath, outputPath, ffmpegOutputArgs());
97
  const convertedBuffer = await fs.readFile(outputPath);
98
 
99
  return {
100
  data: convertedBuffer.toString("base64"),
101
+ format: "mp3"
102
  };
103
  } finally {
104
  await fs.rm(tempDir, { force: true, recursive: true });
 
106
  }
107
  }
108
 
109
+ function ffmpegOutputArgs() {
110
+ return ["-acodec", "libmp3lame", "-q:a", "4"];
111
+ }
112
+
113
+ function inferAudioFormatFromUrl(url) {
114
+ try {
115
+ const pathname = new URL(url).pathname.toLowerCase();
116
+
117
+ if (pathname.endsWith(".m4a")) {
118
+ return "m4a";
119
+ }
120
+
121
+ if (pathname.endsWith(".wav")) {
122
+ return "wav";
123
+ }
124
+
125
+ if (pathname.endsWith(".mp3")) {
126
+ return "mp3";
127
+ }
128
+ } catch (_error) {
129
+ return "unknown";
130
  }
131
 
132
+ return "unknown";
133
+ }
134
+
135
+ function inferAudioFormatFromMimeType(mimeType) {
136
+ const value = String(mimeType || "").split(";")[0].trim().toLowerCase();
137
+
138
+ if (value === "audio/mp4" || value === "audio/x-m4a") {
139
+ return "m4a";
140
+ }
141
+
142
+ if (value === "audio/wav" || value === "audio/x-wav") {
143
+ return "wav";
144
+ }
145
+
146
+ if (value === "audio/mpeg" || value === "audio/mp3") {
147
+ return "mp3";
148
+ }
149
+
150
+ return "unknown";
151
+ }
152
+
153
+ function normalizeInputFormat(format) {
154
+ return ["m4a", "wav", "mp3"].includes(format) ? format : "unknown";
155
  }
src/services/requestNormalizationService.js CHANGED
@@ -2,7 +2,11 @@ import { HttpError } from "../utils/httpError.js";
2
  import { imageMimeType } from "../utils/mediaTypes.js";
3
  import { isDataUrl, isLikelyBase64, parseDataUrl, stripDataUrl } from "../utils/dataUrl.js";
4
 
5
- const SUPPORTED_AUDIO_FORMATS = new Set(["mp3", "wav"]);
 
 
 
 
6
 
7
  function normalizeImageSource(source, mimeType) {
8
  if (typeof source !== "string" || source.length === 0) {
@@ -37,7 +41,7 @@ function normalizeImagePart(part) {
37
  function normalizeAudioBase64(audio) {
38
  const format = audio.format?.toLowerCase();
39
  if (!SUPPORTED_AUDIO_FORMATS.has(format)) {
40
- throw new HttpError(400, "Audio input format must be mp3 or wav.");
41
  }
42
 
43
  if (typeof audio.data !== "string" || audio.data.length === 0) {
@@ -47,10 +51,28 @@ function normalizeAudioBase64(audio) {
47
  const parsed = parseDataUrl(audio.data);
48
  return {
49
  data: stripDataUrl(audio.data),
50
- format: parsed?.mimeType === "audio/wav" ? "wav" : format
51
  };
52
  }
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  export function createRequestNormalizationService({ audioConversionService }) {
55
  return {
56
  async normalize(body) {
@@ -76,9 +98,10 @@ export function createRequestNormalizationService({ audioConversionService }) {
76
 
77
  if (part.type === "input_audio") {
78
  const audio = part.input_audio ?? {};
 
79
 
80
- if (audio.url) {
81
- const converted = await audioConversionService.downloadAndConvertToMp3Base64(audio.url);
82
  nextParts.push({
83
  type: "input_audio",
84
  input_audio: converted
 
2
  import { imageMimeType } from "../utils/mediaTypes.js";
3
  import { isDataUrl, isLikelyBase64, parseDataUrl, stripDataUrl } from "../utils/dataUrl.js";
4
 
5
+ const SUPPORTED_AUDIO_FORMATS = new Set(["mp3", "wav", "m4a"]);
6
+
7
+ function isHttpUrl(value) {
8
+ return typeof value === "string" && (value.startsWith("http://") || value.startsWith("https://"));
9
+ }
10
 
11
  function normalizeImageSource(source, mimeType) {
12
  if (typeof source !== "string" || source.length === 0) {
 
41
  function normalizeAudioBase64(audio) {
42
  const format = audio.format?.toLowerCase();
43
  if (!SUPPORTED_AUDIO_FORMATS.has(format)) {
44
+ throw new HttpError(400, "Audio input format must be mp3, wav, or m4a.");
45
  }
46
 
47
  if (typeof audio.data !== "string" || audio.data.length === 0) {
 
51
  const parsed = parseDataUrl(audio.data);
52
  return {
53
  data: stripDataUrl(audio.data),
54
+ format: inferAudioFormat(parsed?.mimeType, format)
55
  };
56
  }
57
 
58
+ function inferAudioFormat(mimeType, fallbackFormat) {
59
+ const normalizedMimeType = String(mimeType || "").split(";")[0].trim().toLowerCase();
60
+
61
+ if (normalizedMimeType === "audio/wav" || normalizedMimeType === "audio/x-wav") {
62
+ return "wav";
63
+ }
64
+
65
+ if (normalizedMimeType === "audio/mp4" || normalizedMimeType === "audio/x-m4a") {
66
+ return "m4a";
67
+ }
68
+
69
+ if (normalizedMimeType === "audio/mpeg" || normalizedMimeType === "audio/mp3") {
70
+ return "mp3";
71
+ }
72
+
73
+ return fallbackFormat;
74
+ }
75
+
76
  export function createRequestNormalizationService({ audioConversionService }) {
77
  return {
78
  async normalize(body) {
 
98
 
99
  if (part.type === "input_audio") {
100
  const audio = part.input_audio ?? {};
101
+ const audioUrl = audio.url || (isHttpUrl(audio.data) ? audio.data : null);
102
 
103
+ if (audioUrl) {
104
+ const converted = await audioConversionService.downloadAndConvertToMp3Base64(audioUrl);
105
  nextParts.push({
106
  type: "input_audio",
107
  input_audio: converted
src/utils/dataUrl.js CHANGED
@@ -1,4 +1,3 @@
1
- const DATA_URL_PATTERN = /^data:([^;,]+);base64,(.+)$/s;
2
  const BASE64_PATTERN = /^[A-Za-z0-9+/=]+$/;
3
 
4
  export function isDataUrl(value) {
@@ -6,14 +5,28 @@ export function isDataUrl(value) {
6
  }
7
 
8
  export function parseDataUrl(value) {
9
- const match = value.match(DATA_URL_PATTERN);
10
- if (!match) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  return null;
12
  }
13
 
14
  return {
15
- mimeType: match[1],
16
- base64: match[2]
17
  };
18
  }
19
 
 
 
1
  const BASE64_PATTERN = /^[A-Za-z0-9+/=]+$/;
2
 
3
  export function isDataUrl(value) {
 
5
  }
6
 
7
  export function parseDataUrl(value) {
8
+ if (!isDataUrl(value)) {
9
+ return null;
10
+ }
11
+
12
+ const commaIndex = value.indexOf(",");
13
+ if (commaIndex === -1) {
14
+ return null;
15
+ }
16
+
17
+ const metadata = value.slice(5, commaIndex);
18
+ const base64 = value.slice(commaIndex + 1);
19
+ const metadataParts = metadata.split(";");
20
+ const mimeType = metadataParts[0] || "";
21
+ const isBase64 = metadataParts.slice(1).some((part) => part.toLowerCase() === "base64");
22
+
23
+ if (!mimeType || !isBase64 || !base64) {
24
  return null;
25
  }
26
 
27
  return {
28
+ mimeType,
29
+ base64
30
  };
31
  }
32
 
test/app.test.js ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { createApp } from "../src/app.js";
7
+
8
+ test("applies permissive CORS headers to API responses and preflight requests", async () => {
9
+ const publicDir = await mkdtemp(path.join(tmpdir(), "oapix-app-test-"));
10
+ await writeFile(path.join(publicDir, "index.html"), "<!doctype html><title>test</title>");
11
+
12
+ const app = createApp({
13
+ jsonLimit: "1mb",
14
+ publicDir,
15
+ chatController(_req, res) {
16
+ res.json({ ok: true });
17
+ },
18
+ mediaController(_req, res) {
19
+ res.json({ media: true });
20
+ }
21
+ });
22
+
23
+ const server = await new Promise((resolve) => {
24
+ const nextServer = app.listen(0, () => resolve(nextServer));
25
+ });
26
+
27
+ const address = server.address();
28
+ const baseUrl = `http://127.0.0.1:${address.port}`;
29
+
30
+ try {
31
+ const response = await fetch(`${baseUrl}/v1/chat/completions`, {
32
+ method: "POST",
33
+ headers: {
34
+ origin: "https://example.com",
35
+ "content-type": "application/json"
36
+ },
37
+ body: JSON.stringify({ model: "test", messages: [] })
38
+ });
39
+
40
+ assert.equal(response.status, 200);
41
+ assert.equal(response.headers.get("access-control-allow-origin"), "*");
42
+ assert.equal(response.headers.get("access-control-allow-methods"), "GET,POST,PUT,PATCH,DELETE,OPTIONS");
43
+
44
+ const preflight = await fetch(`${baseUrl}/v1/chat/completions`, {
45
+ method: "OPTIONS",
46
+ headers: {
47
+ origin: "https://example.com",
48
+ "access-control-request-method": "POST",
49
+ "access-control-request-headers": "content-type, authorization"
50
+ }
51
+ });
52
+
53
+ assert.equal(preflight.status, 204);
54
+ assert.equal(preflight.headers.get("access-control-allow-origin"), "*");
55
+ assert.equal(preflight.headers.get("access-control-allow-methods"), "GET,POST,PUT,PATCH,DELETE,OPTIONS");
56
+ assert.equal(preflight.headers.get("access-control-allow-headers"), "content-type, authorization");
57
+ } finally {
58
+ await new Promise((resolve, reject) => {
59
+ server.close((error) => {
60
+ if (error) {
61
+ reject(error);
62
+ return;
63
+ }
64
+
65
+ resolve();
66
+ });
67
+ });
68
+ await rm(publicDir, { recursive: true, force: true });
69
+ }
70
+ });
test/requestNormalization.test.js CHANGED
@@ -55,6 +55,48 @@ test("normalizes audio URLs to mp3 base64 and raw image base64 to data URLs", as
55
  assert.equal(responseContext.audioFormat, "wav");
56
  });
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  test("normalizes uploaded wav audio before forwarding upstream", async () => {
59
  const service = createRequestNormalizationService({
60
  audioConversionService: {
@@ -100,3 +142,95 @@ test("normalizes uploaded wav audio before forwarding upstream", async () => {
100
  }
101
  });
102
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  assert.equal(responseContext.audioFormat, "wav");
56
  });
57
 
58
+ test("downloads remote audio when the URL is provided in input_audio.data", async () => {
59
+ const service = createRequestNormalizationService({
60
+ audioConversionService: {
61
+ async downloadAndConvertToMp3Base64(url) {
62
+ assert.equal(url, "https://example.com/sample.mp4");
63
+ return {
64
+ data: "ZG93bmxvYWRlZC1hbmQtY29udmVydGVk",
65
+ format: "mp3"
66
+ };
67
+ },
68
+ async normalizeBase64Audio() {
69
+ throw new Error("unexpected base64 conversion");
70
+ }
71
+ }
72
+ });
73
+
74
+ const { normalizedBody } = await service.normalize({
75
+ messages: [
76
+ {
77
+ role: "user",
78
+ content: [
79
+ {
80
+ type: "input_audio",
81
+ input_audio: {
82
+ data: "https://example.com/sample.mp4",
83
+ format: "m4a"
84
+ }
85
+ }
86
+ ]
87
+ }
88
+ ]
89
+ });
90
+
91
+ assert.deepEqual(normalizedBody.messages[0].content[0], {
92
+ type: "input_audio",
93
+ input_audio: {
94
+ data: "ZG93bmxvYWRlZC1hbmQtY29udmVydGVk",
95
+ format: "mp3"
96
+ }
97
+ });
98
+ });
99
+
100
  test("normalizes uploaded wav audio before forwarding upstream", async () => {
101
  const service = createRequestNormalizationService({
102
  audioConversionService: {
 
142
  }
143
  });
144
  });
145
+
146
+ test("preserves uploaded m4a format for conversion and forwards mp3 output", async () => {
147
+ const service = createRequestNormalizationService({
148
+ audioConversionService: {
149
+ async downloadAndConvertToMp3Base64() {
150
+ throw new Error("unexpected url conversion");
151
+ },
152
+ async normalizeBase64Audio(audio) {
153
+ assert.deepEqual(audio, {
154
+ data: "AAAAGGZ0eXBNNEEg",
155
+ format: "m4a"
156
+ });
157
+
158
+ return {
159
+ data: "Y29udmVydGVkLW1wMw==",
160
+ format: "mp3"
161
+ };
162
+ }
163
+ }
164
+ });
165
+
166
+ const { normalizedBody } = await service.normalize({
167
+ messages: [
168
+ {
169
+ role: "user",
170
+ content: [
171
+ {
172
+ type: "input_audio",
173
+ input_audio: {
174
+ data: "AAAAGGZ0eXBNNEEg",
175
+ format: "m4a"
176
+ }
177
+ }
178
+ ]
179
+ }
180
+ ]
181
+ });
182
+
183
+ assert.deepEqual(normalizedBody.messages[0].content[0], {
184
+ type: "input_audio",
185
+ input_audio: {
186
+ data: "Y29udmVydGVkLW1wMw==",
187
+ format: "mp3"
188
+ }
189
+ });
190
+ });
191
+
192
+ test("parses m4a data urls with codec metadata before conversion", async () => {
193
+ const service = createRequestNormalizationService({
194
+ audioConversionService: {
195
+ async downloadAndConvertToMp3Base64() {
196
+ throw new Error("unexpected url conversion");
197
+ },
198
+ async normalizeBase64Audio(audio) {
199
+ assert.deepEqual(audio, {
200
+ data: "AAAAGGZ0eXBNNEEg",
201
+ format: "m4a"
202
+ });
203
+
204
+ return {
205
+ data: "Y29udmVydGVkLW1wMw==",
206
+ format: "mp3"
207
+ };
208
+ }
209
+ }
210
+ });
211
+
212
+ const { normalizedBody } = await service.normalize({
213
+ messages: [
214
+ {
215
+ role: "user",
216
+ content: [
217
+ {
218
+ type: "input_audio",
219
+ input_audio: {
220
+ data: "data:audio/mp4;codecs=mp4a.40.2;base64,AAAAGGZ0eXBNNEEg",
221
+ format: "m4a"
222
+ }
223
+ }
224
+ ]
225
+ }
226
+ ]
227
+ });
228
+
229
+ assert.deepEqual(normalizedBody.messages[0].content[0], {
230
+ type: "input_audio",
231
+ input_audio: {
232
+ data: "Y29udmVydGVkLW1wMw==",
233
+ format: "mp3"
234
+ }
235
+ });
236
+ });