@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.
- package/LICENSE +21 -0
- package/README.md +151 -2
- package/dist/adapter.d.ts +176 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +195 -0
- package/dist/audio.d.ts +16 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +80 -0
- package/dist/catalog.d.ts +104 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +307 -0
- package/dist/client.d.ts +64 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +148 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/model-memory.d.ts +46 -0
- package/dist/model-memory.d.ts.map +1 -0
- package/dist/model-memory.js +79 -0
- package/dist/model-meta.d.ts +7 -0
- package/dist/model-meta.d.ts.map +1 -0
- package/dist/model-meta.js +5 -0
- package/dist/shared-load.d.ts +48 -0
- package/dist/shared-load.d.ts.map +1 -0
- package/dist/shared-load.js +103 -0
- package/dist/store.d.ts +8 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +17 -0
- package/dist/template.d.ts +26 -0
- package/dist/template.d.ts.map +1 -0
- package/dist/template.js +25 -0
- package/dist/thinking.d.ts +39 -0
- package/dist/thinking.d.ts.map +1 -0
- package/dist/thinking.js +140 -0
- package/dist/tool-calls.d.ts +33 -0
- package/dist/tool-calls.d.ts.map +1 -0
- package/dist/tool-calls.js +203 -0
- package/package.json +63 -4
- package/src/adapter.ts +345 -0
- package/src/audio.test.ts +83 -0
- package/src/audio.ts +79 -0
- package/src/catalog.ts +399 -0
- package/src/client.ts +195 -0
- package/src/index.ts +11 -0
- package/src/runtime.test.ts +220 -0
- package/src/wllama-version.test.ts +19 -0
- 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
|
+
}
|