@tanstack-ai-browser/wllama 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +151 -2
  3. package/dist/adapter.d.ts +176 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +195 -0
  6. package/dist/audio.d.ts +16 -0
  7. package/dist/audio.d.ts.map +1 -0
  8. package/dist/audio.js +80 -0
  9. package/dist/catalog.d.ts +104 -0
  10. package/dist/catalog.d.ts.map +1 -0
  11. package/dist/catalog.js +307 -0
  12. package/dist/client.d.ts +64 -0
  13. package/dist/client.d.ts.map +1 -0
  14. package/dist/client.js +148 -0
  15. package/dist/index.d.ts +12 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +7 -0
  18. package/dist/model-memory.d.ts +46 -0
  19. package/dist/model-memory.d.ts.map +1 -0
  20. package/dist/model-memory.js +79 -0
  21. package/dist/model-meta.d.ts +7 -0
  22. package/dist/model-meta.d.ts.map +1 -0
  23. package/dist/model-meta.js +5 -0
  24. package/dist/shared-load.d.ts +48 -0
  25. package/dist/shared-load.d.ts.map +1 -0
  26. package/dist/shared-load.js +103 -0
  27. package/dist/store.d.ts +8 -0
  28. package/dist/store.d.ts.map +1 -0
  29. package/dist/store.js +17 -0
  30. package/dist/template.d.ts +26 -0
  31. package/dist/template.d.ts.map +1 -0
  32. package/dist/template.js +25 -0
  33. package/dist/thinking.d.ts +39 -0
  34. package/dist/thinking.d.ts.map +1 -0
  35. package/dist/thinking.js +140 -0
  36. package/dist/tool-calls.d.ts +33 -0
  37. package/dist/tool-calls.d.ts.map +1 -0
  38. package/dist/tool-calls.js +203 -0
  39. package/package.json +63 -4
  40. package/src/adapter.ts +345 -0
  41. package/src/audio.test.ts +83 -0
  42. package/src/audio.ts +79 -0
  43. package/src/catalog.ts +399 -0
  44. package/src/client.ts +195 -0
  45. package/src/index.ts +11 -0
  46. package/src/runtime.test.ts +220 -0
  47. package/src/wllama-version.test.ts +19 -0
  48. package/src/wrapper.test.ts +186 -0
package/src/adapter.ts ADDED
@@ -0,0 +1,345 @@
1
+ /**
2
+ * A TanStack AI text adapter for GGUF models that run in the browser with
3
+ * wllama (llama.cpp in WebAssembly). wllama answers llama.cpp's
4
+ * OpenAI-compatible Chat Completions API in the page, so this is TanStack's
5
+ * Chat Completions adapter with the loaded model as its client: messages,
6
+ * tools, structured output and the stream (text, thinking, tool calls) are
7
+ * TanStack's, and llama.cpp writes the prompt with the model's chat template
8
+ * and reads its thinking and tool calls back. This file adds what's wllama's
9
+ * own: loading the model with the adapter's settings, the chat template's
10
+ * thinking switch, the model card's sampling, and audio parts for the
11
+ * models that read them.
12
+ */
13
+ import type { ReasoningRequest, TextOptions, TokenUsage } from "@tanstack/ai"
14
+ import { resolveReasoning } from "@tanstack/ai/adapter-internals"
15
+ import { OpenAIBaseChatCompletionsTextAdapter } from "@tanstack/openai-base"
16
+ import type { OpenAIClientLike } from "@tanstack/openai-base"
17
+ import type { WllamaCompat } from "@wllama/wllama/esm/index.js"
18
+
19
+ import { llamaAudio } from "./audio.js"
20
+ import { availability, loadModel, unloadModel } from "./client.js"
21
+ import type { Availability, DownloadProgressCallback, LoadSettings } from "./client.js"
22
+ import { listed, modelMeta } from "./catalog.js"
23
+ import type { Sampling, WllamaChatModel, WllamaModel, WllamaModelReasoningByName } from "./catalog.js"
24
+
25
+ export type WllamaConfig = {
26
+ /**
27
+ * Most tokens a chat may hold, prompt and reply together: the model's
28
+ * whole context window by default (its GGUF's training context), never
29
+ * more. llama.cpp reserves the attention cache for all of it when the
30
+ * model loads (LFM3: ~12 KB a token, so 3 GB for its 262,144), so set
31
+ * less where memory is short: 8,192 on an iPhone. Changing it loads the
32
+ * model again.
33
+ */
34
+ contextLength?: number
35
+ /**
36
+ * Where wllama's WebAssembly file is served. By default, the installed
37
+ * `@wllama/wllama`'s own file on jsDelivr, at its version; serve
38
+ * `@wllama/wllama/esm/wasm/wllama.wasm` with the site and pass its URL to
39
+ * host it yourself.
40
+ */
41
+ wasmUrl?: string
42
+ /**
43
+ * Where wllama's compat build (for Safari: `@wllama/wllama-compat`'s
44
+ * `wasm/wllama.wasm` and `wasm/wllama.js`) is served, passed to
45
+ * `Wllama.setCompat`. By default, wllama's: the same version on jsDelivr.
46
+ */
47
+ compat?: WllamaCompat
48
+ /** CPU threads. wllama's default otherwise: half the cores. */
49
+ threads?: number
50
+ /**
51
+ * `false` to run on the CPU only. By default wllama runs on WebGPU where
52
+ * it can, and on the CPU elsewhere.
53
+ */
54
+ gpu?: boolean
55
+ /** wllama's and llama.cpp's logs, in the console. */
56
+ debug?: boolean
57
+ /**
58
+ * The model's download progress, 0 to 1, when a call (`chat()`, say)
59
+ * starts its download; then 1 once it's ready. To load it ahead of time
60
+ * with progress, see `create`.
61
+ */
62
+ onDownloadProgress?: DownloadProgressCallback
63
+ }
64
+
65
+ /**
66
+ * Per-call `modelOptions`: llama.cpp's own request fields, sent as given
67
+ * (wllama answers with llama.cpp's OpenAI-compatible server code), over the
68
+ * model card's sampling. These are the common ones; llama.cpp's others go
69
+ * through too.
70
+ */
71
+ export type WllamaTextModelOptions = {
72
+ /** Most tokens to generate, thinking included. llama.cpp's default otherwise: up to the context limit. */
73
+ max_tokens?: number
74
+ /** Randomness of sampling: 0 is greedy. */
75
+ temperature?: number
76
+ /** Nucleus sampling: the likeliest tokens up to this probability, in (0, 1]. */
77
+ top_p?: number
78
+ /** Sampling from the likeliest `top_k` tokens only (0: all of them). */
79
+ top_k?: number
80
+ /** Drops tokens less likely than this share of the likeliest one's odds. */
81
+ min_p?: number
82
+ /** Penalizes tokens already in the chat (1: off). */
83
+ repeat_penalty?: number
84
+ /** Lowers the odds of tokens by how often they've appeared. */
85
+ frequency_penalty?: number
86
+ /** Lowers the odds of tokens that have appeared at all. */
87
+ presence_penalty?: number
88
+ /** Seeds the sampler, for the same samples from the same request. */
89
+ seed?: number
90
+ /** Where to stop: the reply ends before the first of these it would write. */
91
+ stop?: string | Array<string>
92
+ /** Adds to the odds of tokens, by token id. */
93
+ logit_bias?: Record<string, number>
94
+ /**
95
+ * False constrains the model to one tool call. True or left out: as many
96
+ * as the model writes, when its chat template supports several.
97
+ */
98
+ parallel_tool_calls?: boolean
99
+ /** The chat template's own variables; `chat({ reasoning })` sets its `enable_thinking`. */
100
+ chat_template_kwargs?: Record<string, unknown>
101
+ }
102
+
103
+ /** A model's reasoning levels: a listed model's, none for a listed one that doesn't reason, else the switch. */
104
+ type ResolveReasoning<TModel extends string> = TModel extends keyof WllamaModelReasoningByName
105
+ ? WllamaModelReasoningByName[TModel]
106
+ : TModel extends WllamaChatModel
107
+ ? never
108
+ : { levels: "off" | "high"; budget: true }
109
+
110
+ /** The switch for a model the catalog doesn't list: the chat template's `enable_thinking`. */
111
+ const UNLISTED_REASONING = { levels: { off: "false", high: "true" }, budget: true } as const
112
+
113
+ /** A model's reasoning levels: the catalog's, or the switch for one it doesn't list. */
114
+ const reasoningOf = (model: WllamaModel) => (listed(model) ? modelMeta(model).reasoning : UNLISTED_REASONING)
115
+
116
+ type MetadataByModality = { text: unknown; image: unknown; audio: unknown; video: unknown; document: unknown }
117
+
118
+ /** The base adapter's conversion of a message part to a Chat Completions one. */
119
+ type ConvertContentPart = OpenAIBaseChatCompletionsTextAdapter<string>["convertContentPart"]
120
+
121
+ /** A Chat Completions request, as the base adapter writes it. */
122
+ type CreateParams = Parameters<OpenAIClientLike["chat"]["completions"]["create"]>[0]
123
+
124
+ /**
125
+ * The chat template's `enable_thinking` for a request, through llama.cpp's
126
+ * `chat_template_kwargs`, and a thinking budget: left out when the model has
127
+ * no levels or the request doesn't ask, so the template's default applies.
128
+ */
129
+ function thinkingKwargs(
130
+ request: ReasoningRequest | undefined,
131
+ model: WllamaModel,
132
+ kwargs: Record<string, unknown> | undefined
133
+ ): Record<string, unknown> {
134
+ const value = resolveReasoning(request, reasoningOf(model))?.value
135
+ if (value === undefined) return {}
136
+ const thinking = value === "true"
137
+ const budget = request?.budgetTokens
138
+ return {
139
+ chat_template_kwargs: { ...kwargs, enable_thinking: thinking },
140
+ ...(thinking && budget !== undefined && { reasoning_budget_tokens: budget })
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The card's sampling for a request: its thinking settings while the chat
146
+ * template thinks (asked for, or a model that always thinks), else its
147
+ * own. None for a model the catalog doesn't list: llama.cpp's defaults.
148
+ */
149
+ function cardSampling(request: ReasoningRequest | undefined, model: WllamaModel): Partial<Sampling> {
150
+ if (!listed(model)) return {}
151
+ const meta = modelMeta(model)
152
+ const value = resolveReasoning(request, meta.reasoning)?.value
153
+ const thinking = value !== undefined ? value === "true" : !!meta.reasoning && !("off" in meta.reasoning.levels)
154
+ return (thinking && meta.thinkingSampling) || meta.sampling
155
+ }
156
+
157
+ /** What a URL holds: a data URL's bytes, or those the page fetches. */
158
+ const bytesAt = async (url: string, signal?: AbortSignal) => (await fetch(url, { ...(signal && { signal }) })).arrayBuffer()
159
+
160
+ /** A Chat Completions part that names its media by URL. */
161
+ type MediaPart = { type?: string; image_url?: { url: string }; audio_url?: { url: string } }
162
+
163
+ /**
164
+ * A request's messages with each image and audio clip as its bytes: wllama
165
+ * reads them as `{ type: "image", data }` and `{ type: "audio", data }`,
166
+ * where the request has an `image_url` or an `audio_url` (a data URL here,
167
+ * or one the page can fetch). Audio llama.cpp can't decode goes as WAV.
168
+ */
169
+ async function withMediaBytes(messages: unknown, signal?: AbortSignal): Promise<unknown> {
170
+ if (!Array.isArray(messages)) return messages
171
+ return Promise.all(
172
+ messages.map(async (message: { content?: unknown }) => {
173
+ if (!Array.isArray(message.content)) return message
174
+ const content = await Promise.all(
175
+ message.content.map(async (part: MediaPart) => {
176
+ if (part.type === "image_url" && part.image_url) {
177
+ return { type: "image", data: await bytesAt(part.image_url.url, signal) }
178
+ }
179
+ if (part.type === "audio_url" && part.audio_url) {
180
+ return { type: "audio", data: await llamaAudio(await bytesAt(part.audio_url.url, signal)) }
181
+ }
182
+ return part
183
+ })
184
+ )
185
+ return { ...message, content }
186
+ })
187
+ )
188
+ }
189
+
190
+ /**
191
+ * The model as an OpenAI client: each request loads it with the adapter's
192
+ * settings (at once when it's loaded with them) and goes to wllama's
193
+ * `createChatCompletion`.
194
+ */
195
+ function clientFor(model: WllamaModel, settings: LoadSettings, onDownloadProgress?: DownloadProgressCallback): OpenAIClientLike {
196
+ const create = async (body: Record<string, unknown>, options?: { signal?: AbortSignal | null }) => {
197
+ const signal = options?.signal ?? undefined
198
+ const wllama = await loadModel(model, settings, { ...(onDownloadProgress && { onDownloadProgress }), ...(signal && { signal }) })
199
+ const messages = await withMediaBytes(body.messages, signal)
200
+ return wllama.createChatCompletion({ ...body, messages, ...(signal && { abortSignal: signal }) } as never)
201
+ }
202
+ return { baseURL: "wllama:", chat: { completions: { create } } } as unknown as OpenAIClientLike
203
+ }
204
+
205
+ /** The settings a model loads with, from the adapter's config. */
206
+ const loadSettings = ({ contextLength, wasmUrl, compat, threads, gpu, debug }: WllamaConfig): LoadSettings => ({
207
+ ...(contextLength !== undefined && { contextLength }),
208
+ ...(wasmUrl && { wasmUrl }),
209
+ ...(compat && { compat }),
210
+ ...(threads && { threads }),
211
+ ...(gpu !== undefined && { gpu }),
212
+ ...(debug && { debug })
213
+ })
214
+
215
+ export class WllamaTextAdapter<TModel extends WllamaModel> extends OpenAIBaseChatCompletionsTextAdapter<
216
+ TModel,
217
+ WllamaTextModelOptions,
218
+ ReadonlyArray<"text" | "image" | "audio">,
219
+ MetadataByModality,
220
+ ReadonlyArray<string>,
221
+ ResolveReasoning<TModel>
222
+ > {
223
+ override readonly name = "wllama"
224
+ override readonly api = "wllama"
225
+ /** Text, and images or audio for a listed model whose projector reads them. */
226
+ override readonly inputModalities: ReadonlyArray<"text" | "image" | "audio">
227
+
228
+ private readonly settings: LoadSettings
229
+ private readonly onDownloadProgress: DownloadProgressCallback | undefined
230
+
231
+ constructor(config: WllamaConfig, model: TModel) {
232
+ const settings = loadSettings(config)
233
+ super(model, "wllama", clientFor(model, settings, config.onDownloadProgress), { strictFallbackWarning: false })
234
+ this.settings = settings
235
+ this.onDownloadProgress = config.onDownloadProgress
236
+ this.inputModalities = listed(model) ? modelMeta(model).supports.input : ["text"]
237
+ }
238
+
239
+ /**
240
+ * Where the model stands in this browser, with the Prompt API's values:
241
+ * "available" once it's in wllama's storage (or loaded), "downloading"
242
+ * while this page downloads it, else "downloadable"; "unavailable" where
243
+ * wllama can't run.
244
+ */
245
+ availability(): Promise<Availability> {
246
+ return availability(this.model)
247
+ }
248
+
249
+ /**
250
+ * Downloads the model if it isn't stored and loads it with this adapter's
251
+ * settings, with the download's progress, 0 to 1, then 1 once it's ready.
252
+ * Resolves to this adapter; rejects if it can't load (fine to leave
253
+ * unawaited). `chat()` doesn't need it: its first call loads the model.
254
+ */
255
+ create(options: { onDownloadProgress?: DownloadProgressCallback; signal?: AbortSignal } = {}): Promise<this> {
256
+ const own = this.onDownloadProgress
257
+ const theirs = options.onDownloadProgress
258
+ const ready = loadModel(this.model, this.settings, {
259
+ onDownloadProgress: (progress) => {
260
+ own?.(progress)
261
+ theirs?.(progress)
262
+ },
263
+ ...(options.signal && { signal: options.signal })
264
+ }).then(() => this)
265
+ ready.catch(() => undefined)
266
+ return ready
267
+ }
268
+
269
+ /** Unloads the model, freeing its memory. It stays in wllama's storage. */
270
+ async destroy(): Promise<void> {
271
+ await unloadModel(this.model)
272
+ }
273
+
274
+ /** The request, with `modelOptions` as given over the model card's sampling, and the chat template's thinking switch. */
275
+ /**
276
+ * The engine's usage, as TanStack converts it, but with a cached count of 0
277
+ * kept, which TanStack's conversion leaves out: 0 means the whole prompt was
278
+ * read, where no count means the engine didn't say.
279
+ */
280
+ protected override chatCompletionsTokenUsage(
281
+ usage: { prompt_tokens_details?: { cached_tokens?: number } | null } | null | undefined,
282
+ source?: unknown
283
+ ): TokenUsage | undefined {
284
+ const result = super.chatCompletionsTokenUsage(usage as never, source)
285
+ const cachedTokens = usage?.prompt_tokens_details?.cached_tokens
286
+ if (!result || cachedTokens !== 0) return result
287
+ return { ...result, promptTokensDetails: { ...result.promptTokensDetails, cachedTokens } }
288
+ }
289
+
290
+ protected override mapOptionsToRequest(options: TextOptions): CreateParams & { stream: true } {
291
+ const modelOptions = options.modelOptions as WllamaTextModelOptions | undefined
292
+ const settings = {
293
+ ...cardSampling(options.reasoning, this.model),
294
+ ...modelOptions,
295
+ ...(options.promptCache?.retention === "none" && { cache_prompt: false })
296
+ }
297
+ const request = super.mapOptionsToRequest({ ...options, modelOptions: settings, reasoning: undefined })
298
+ return Object.assign(request, thinkingKwargs(options.reasoning, this.model, modelOptions?.chat_template_kwargs))
299
+ }
300
+
301
+ /** llama.cpp streams thinking as `reasoning_content`. */
302
+ protected override extractReasoning(chunk: unknown) {
303
+ const text = (chunk as { choices?: Array<{ delta?: { reasoning_content?: string } }> }).choices?.[0]?.delta?.reasoning_content
304
+ return text ? { text } : undefined
305
+ }
306
+
307
+ /**
308
+ * An audio part by its URL, as an image goes: Chat Completions takes
309
+ * audio only as base64 in two formats, and the client reads the bytes of
310
+ * either kind from a URL (a data URL for base64).
311
+ */
312
+ protected override convertContentPart(part: Parameters<ConvertContentPart>[0]): ReturnType<ConvertContentPart> {
313
+ if (part.type !== "audio") return super.convertContentPart(part)
314
+ const { source } = part
315
+ if (source.type === "file") {
316
+ throw new Error("wllama takes audio as data or URLs, not as uploaded files.")
317
+ }
318
+ const url =
319
+ source.type === "data" && !source.value.startsWith("data:")
320
+ ? `data:${source.mimeType || "application/octet-stream"};base64,${source.value}`
321
+ : source.value
322
+ return { type: "audio_url", audio_url: { url } } as unknown as ReturnType<ConvertContentPart>
323
+ }
324
+
325
+ /** Schemas as given: llama.cpp's grammar takes any JSON Schema, without OpenAI's strict-mode rewrite. */
326
+ protected override makeStructuredOutputCompatible(schema: Record<string, any>) {
327
+ return schema
328
+ }
329
+ }
330
+
331
+ /**
332
+ * A TanStack AI text adapter for a GGUF model that wllama runs in the
333
+ * browser.
334
+ *
335
+ * @example
336
+ * ```ts
337
+ * chat({ adapter: wllamaText("unsloth/Qwen3.5-2B-GGUF"), messages, threadId })
338
+ * ```
339
+ */
340
+ export function wllamaText<TModel extends WllamaModel>(
341
+ model: TModel,
342
+ config: WllamaConfig = {}
343
+ ): WllamaTextAdapter<TModel> {
344
+ return new WllamaTextAdapter(config, model)
345
+ }
@@ -0,0 +1,83 @@
1
+ import assert from "node:assert/strict"
2
+ import { afterEach, test } from "node:test"
3
+
4
+ import { decodesInLlama, llamaAudio, wavFile } from "./audio.js"
5
+
6
+ const bytes = (...values: Array<number | string>) =>
7
+ new Uint8Array(values.flatMap((value) => (typeof value === "string" ? [...value].map((c) => c.charCodeAt(0)) : [value]))).buffer
8
+
9
+ const globals = globalThis as { OfflineAudioContext?: unknown }
10
+ afterEach(() => {
11
+ delete globals.OfflineAudioContext
12
+ })
13
+
14
+ test("WAV, FLAC and MP3 files are the ones llama.cpp decodes itself", () => {
15
+ assert.equal(decodesInLlama(bytes("RIFF", 0, 0, 0, 0, "WAVE")), true)
16
+ assert.equal(decodesInLlama(bytes("fLaC", 0, 0, 0, 0)), true)
17
+ assert.equal(decodesInLlama(bytes("ID3", 4, 0, 0)), true)
18
+ // An MP3 frame: MPEG-1 Layer III.
19
+ assert.equal(decodesInLlama(bytes(0xff, 0xfb, 0x90, 0x00)), true)
20
+ // What browsers record: Opus in Ogg or WebM, AAC (ADTS, or in MP4).
21
+ assert.equal(decodesInLlama(bytes("OggS", 0, 2, 0, 0)), false)
22
+ assert.equal(decodesInLlama(bytes(0x1a, 0x45, 0xdf, 0xa3)), false)
23
+ assert.equal(decodesInLlama(bytes(0xff, 0xf1, 0x50, 0x80)), false)
24
+ assert.equal(decodesInLlama(bytes(0, 0, 0, 0x20, "ftypM4A ")), false)
25
+ // A RIFF file that isn't audio (WebP), and one too short to tell.
26
+ assert.equal(decodesInLlama(bytes("RIFF", 0, 0, 0, 0, "WEBP")), false)
27
+ assert.equal(decodesInLlama(bytes("RI")), false)
28
+ })
29
+
30
+ test("samples are written as a mono 16-bit PCM WAV file", () => {
31
+ const file = new DataView(wavFile(new Float32Array([0, 1, -1, 2, 0.5]), 16000))
32
+ const text = (at: number) => String.fromCharCode(...[0, 1, 2, 3].map((i) => file.getUint8(at + i)))
33
+ assert.deepEqual([text(0), text(8), text(12), text(36)], ["RIFF", "WAVE", "fmt ", "data"])
34
+ assert.equal(file.byteLength, 44 + 10)
35
+ assert.equal(file.getUint32(4, true), 36 + 10)
36
+ // PCM, one channel, 16,000 samples a second, 2 bytes each.
37
+ assert.deepEqual(
38
+ [file.getUint16(20, true), file.getUint16(22, true), file.getUint32(24, true), file.getUint32(28, true), file.getUint16(34, true)],
39
+ [1, 1, 16000, 32000, 16]
40
+ )
41
+ assert.equal(file.getUint32(40, true), 10)
42
+ // Full scale either way, a sample past it clipped.
43
+ assert.deepEqual(
44
+ [0, 1, 2, 3, 4].map((i) => file.getInt16(44 + i * 2, true)),
45
+ [0, 32767, -32768, 32767, 16384]
46
+ )
47
+ assert.equal(decodesInLlama(file.buffer), true)
48
+ })
49
+
50
+ test("a file llama.cpp decodes goes as it is; another is decoded by the browser, mixed to mono, and goes as WAV", async () => {
51
+ const wav = wavFile(new Float32Array([0.25]), 8000)
52
+ assert.equal(await llamaAudio(wav), wav)
53
+
54
+ const opus = bytes("OggS", 0, 2, 0, 0)
55
+ await assert.rejects(llamaAudio(opus), /no Web Audio/)
56
+
57
+ let decodedAt = 0
58
+ globals.OfflineAudioContext = class {
59
+ constructor(_channels: number, _length: number, sampleRate: number) {
60
+ decodedAt = sampleRate
61
+ }
62
+ async decodeAudioData(data: ArrayBuffer) {
63
+ // A copy was handed over: the caller's bytes stay usable.
64
+ assert.notEqual(data, opus)
65
+ const channels = [new Float32Array([1, -1, 0.5]), new Float32Array([0, 0, 0.5])]
66
+ return { length: 3, numberOfChannels: 2, sampleRate: 16000, getChannelData: (channel: number) => channels[channel] }
67
+ }
68
+ }
69
+ const file = new DataView(await llamaAudio(opus))
70
+ assert.equal(decodedAt, 16000)
71
+ assert.equal(file.getUint32(24, true), 16000)
72
+ assert.deepEqual(
73
+ [0, 1, 2].map((i) => file.getInt16(44 + i * 2, true)),
74
+ [16384, -16384, 16384]
75
+ )
76
+
77
+ globals.OfflineAudioContext = class {
78
+ async decodeAudioData() {
79
+ throw new DOMException("Unable to decode audio data", "EncodingError")
80
+ }
81
+ }
82
+ await assert.rejects(llamaAudio(opus), /couldn't decode this audio/)
83
+ })
package/src/audio.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Audio as llama.cpp reads it. Its multimodal library decodes WAV, MP3 and
3
+ * FLAC files itself, and resamples them to the model's rate. Anything else
4
+ * a browser records or plays (Opus in WebM or Ogg, AAC in MP4) is decoded
5
+ * here, with Web Audio, and handed over as WAV.
6
+ */
7
+
8
+ /** The rate other formats are decoded at: the audio encoders' own, so llama.cpp resamples nothing. */
9
+ const SAMPLE_RATE = 16000
10
+
11
+ /** Whether `bytes` holds `text` (ASCII) at `at`. */
12
+ const holds = (bytes: Uint8Array, at: number, text: string) =>
13
+ [...text].every((character, i) => bytes[at + i] === character.charCodeAt(0))
14
+
15
+ /** Whether `data` is a WAV, FLAC or MP3 file, by how it starts: the files llama.cpp decodes. */
16
+ export function decodesInLlama(data: ArrayBuffer): boolean {
17
+ const bytes = new Uint8Array(data, 0, Math.min(data.byteLength, 12))
18
+ if (bytes.length < 4) return false
19
+ if (holds(bytes, 0, "RIFF") && holds(bytes, 8, "WAVE")) return true
20
+ if (holds(bytes, 0, "fLaC")) return true
21
+ // An MP3 that opens with an ID3 tag, or with a frame: 11 sync bits, then
22
+ // Layer III (AAC's ADTS frames open the same way, as layer 0).
23
+ if (holds(bytes, 0, "ID3")) return true
24
+ const [first = 0, second = 0] = bytes
25
+ return first === 0xff && (second & 0xe0) === 0xe0 && (second & 0x06) === 0x02
26
+ }
27
+
28
+ /** Mono samples, each from -1 to 1, as a 16-bit PCM WAV file. */
29
+ export function wavFile(samples: Float32Array, sampleRate: number): ArrayBuffer {
30
+ const size = samples.length * 2
31
+ const file = new DataView(new ArrayBuffer(44 + size))
32
+ const text = (at: number, value: string) => {
33
+ for (let i = 0; i < value.length; i++) file.setUint8(at + i, value.charCodeAt(i))
34
+ }
35
+ text(0, "RIFF")
36
+ file.setUint32(4, 36 + size, true)
37
+ text(8, "WAVE")
38
+ text(12, "fmt ")
39
+ file.setUint32(16, 16, true)
40
+ // PCM, one channel, 2 bytes a sample.
41
+ file.setUint16(20, 1, true)
42
+ file.setUint16(22, 1, true)
43
+ file.setUint32(24, sampleRate, true)
44
+ file.setUint32(28, sampleRate * 2, true)
45
+ file.setUint16(32, 2, true)
46
+ file.setUint16(34, 16, true)
47
+ text(36, "data")
48
+ file.setUint32(40, size, true)
49
+ samples.forEach((value, i) => {
50
+ const sample = Math.max(-1, Math.min(1, value))
51
+ file.setInt16(44 + i * 2, Math.round(sample < 0 ? sample * 0x8000 : sample * 0x7fff), true)
52
+ })
53
+ return file.buffer
54
+ }
55
+
56
+ /**
57
+ * An audio file's bytes as llama.cpp can decode them: as they are for WAV,
58
+ * MP3 and FLAC, else decoded by the browser and written as mono WAV.
59
+ */
60
+ export async function llamaAudio(data: ArrayBuffer): Promise<ArrayBuffer> {
61
+ if (decodesInLlama(data)) return data
62
+ if (typeof OfflineAudioContext === "undefined") {
63
+ throw new Error("This audio isn't WAV, MP3 or FLAC, and there's no Web Audio here to decode it.")
64
+ }
65
+ let decoded: AudioBuffer
66
+ try {
67
+ // A copy: decoding detaches the buffer it's given.
68
+ decoded = await new OfflineAudioContext(1, 1, SAMPLE_RATE).decodeAudioData(data.slice(0))
69
+ } catch {
70
+ throw new Error("The browser couldn't decode this audio. WAV, MP3 and FLAC always work.")
71
+ }
72
+ const mono = new Float32Array(decoded.length)
73
+ for (let channel = 0; channel < decoded.numberOfChannels; channel++) {
74
+ decoded.getChannelData(channel).forEach((sample, i) => {
75
+ mono[i] = (mono[i] ?? 0) + sample / decoded.numberOfChannels
76
+ })
77
+ }
78
+ return wavFile(mono, decoded.sampleRate)
79
+ }