opencharm 0.0.0 → 0.1.0
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/README.md +30 -4
- package/dist/main.mjs +4157 -0
- package/package.json +51 -4
- package/sim/THIRD_PARTY_NOTICES.md +95 -0
- package/sim/charm_sim.js +2 -0
- package/sim/charm_sim.wasm +0 -0
- package/sim/index.html +66 -0
- package/sim/mic-worklet.js +22 -0
- package/sim/sim.css +148 -0
- package/sim/sim.js +402 -0
- package/LICENSE +0 -21
package/sim/sim.js
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// The browser half of the emulator's HAL. The charm's behaviour runs in WebAssembly (charm_sim.js,
|
|
2
|
+
// built from firmware/core); this file only moves pixels, input, audio and WebSocket frames.
|
|
3
|
+
import createCharm from "./charm_sim.js";
|
|
4
|
+
|
|
5
|
+
// Settings come from the address (?shape=…), or from the desktop app, which sets them before loading.
|
|
6
|
+
const params = new URLSearchParams(window.charmParams ?? location.search);
|
|
7
|
+
// `opencharm sim` serves config.json; the desktop app has none (some hosts answer a missing file
|
|
8
|
+
// with a page that isn't JSON), so any failure here just means the defaults.
|
|
9
|
+
const config = await fetch("config.json")
|
|
10
|
+
.then((r) => (r.ok ? r.json() : {}))
|
|
11
|
+
.catch(() => ({}));
|
|
12
|
+
const baseUrl = params.get("url") || config.url || "ws://127.0.0.1:8787/charm";
|
|
13
|
+
// Three screens: the square board, a round one, and the desktop charm under a Mac's notch (spec 013):
|
|
14
|
+
// a 360 x 180 pt panel drawn at 2x, with the eyes on the ears either side of a 180 pt notch.
|
|
15
|
+
const shape =
|
|
16
|
+
params.get("shape") ?? (params.get("round") === "1" ? "round" : "square");
|
|
17
|
+
const round = shape === "round";
|
|
18
|
+
const notch = shape === "notch";
|
|
19
|
+
// The desktop app passes its real panel and notch size (pixels at 2x); the emulator uses a default.
|
|
20
|
+
const num = (name, fallback) => Number(params.get(name)) || fallback;
|
|
21
|
+
const W = notch ? num("w", 720) : round ? 466 : 480;
|
|
22
|
+
const H = notch ? num("h", 360) : W;
|
|
23
|
+
const NOTCH = { width: num("notch", 360), strip: num("strip", 72) };
|
|
24
|
+
// For automated tests only: a soft tone instead of the microphone, so no real mic is ever opened.
|
|
25
|
+
const fakeMic = params.get("mic") === "fake";
|
|
26
|
+
|
|
27
|
+
const canvas = document.getElementById("screen");
|
|
28
|
+
const statusEl = document.getElementById("status");
|
|
29
|
+
const charmEl = document.getElementById("charm");
|
|
30
|
+
const keyEl = document.getElementById("key");
|
|
31
|
+
canvas.width = W;
|
|
32
|
+
canvas.height = H;
|
|
33
|
+
const scale = notch ? 0.5 : 1;
|
|
34
|
+
canvas.style.width = `${W * scale}px`;
|
|
35
|
+
canvas.style.height = `${H * scale}px`;
|
|
36
|
+
charmEl.style.width = `${W * scale + (notch ? 0 : 40)}px`;
|
|
37
|
+
charmEl.classList.toggle("round", round);
|
|
38
|
+
charmEl.classList.toggle("notch", notch);
|
|
39
|
+
if (notch) {
|
|
40
|
+
charmEl.style.setProperty("--strip", `${NOTCH.strip * scale}px`);
|
|
41
|
+
charmEl.style.setProperty("--panel", `${H * scale}px`);
|
|
42
|
+
charmEl.style.setProperty("--notch", `${NOTCH.width * scale}px`);
|
|
43
|
+
}
|
|
44
|
+
document.getElementById("url").textContent = baseUrl;
|
|
45
|
+
|
|
46
|
+
// For tests (Playwright) and curious people: everything the charm received, in order.
|
|
47
|
+
const trace = {
|
|
48
|
+
messages: [],
|
|
49
|
+
audioFrames: 0,
|
|
50
|
+
micFrames: 0,
|
|
51
|
+
connected: false,
|
|
52
|
+
micReady: false, // the microphone is open right now (only while the key is held)
|
|
53
|
+
micOpens: 0, // how many times it was opened
|
|
54
|
+
};
|
|
55
|
+
window.__charm = trace;
|
|
56
|
+
|
|
57
|
+
let ws;
|
|
58
|
+
let wifiOn = true;
|
|
59
|
+
let micOn = false;
|
|
60
|
+
let keyHeld = false;
|
|
61
|
+
let mic; // {stream, ctx} while the key is held; the microphone is closed otherwise
|
|
62
|
+
let playCtx;
|
|
63
|
+
let playAt = 0;
|
|
64
|
+
const playing = new Set();
|
|
65
|
+
|
|
66
|
+
window.charmSim = {
|
|
67
|
+
// A host page (the desktop app) can watch what charmd sends: set to (message) => {}.
|
|
68
|
+
onMessage: null,
|
|
69
|
+
sendText: (json) => ws?.readyState === 1 && ws.send(json),
|
|
70
|
+
sendBinary: (bytes) => {
|
|
71
|
+
if (ws?.readyState === 1) {
|
|
72
|
+
ws.send(bytes);
|
|
73
|
+
trace.micFrames += 1;
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
mic: (on) => {
|
|
77
|
+
micOn = on;
|
|
78
|
+
},
|
|
79
|
+
playPcm: (pcm) => {
|
|
80
|
+
if (!playCtx) return;
|
|
81
|
+
const buffer = playCtx.createBuffer(1, pcm.length, 24000);
|
|
82
|
+
buffer.copyToChannel(pcm, 0);
|
|
83
|
+
const source = playCtx.createBufferSource();
|
|
84
|
+
source.buffer = buffer;
|
|
85
|
+
source.connect(playCtx.destination);
|
|
86
|
+
playAt = Math.max(playAt, playCtx.currentTime + 0.05);
|
|
87
|
+
source.start(playAt);
|
|
88
|
+
playAt += buffer.duration;
|
|
89
|
+
playing.add(source);
|
|
90
|
+
source.onended = () => {
|
|
91
|
+
playing.delete(source);
|
|
92
|
+
if (playing.size === 0) idleSpeaker();
|
|
93
|
+
};
|
|
94
|
+
if (playCtx.state === "suspended") void playCtx.resume();
|
|
95
|
+
},
|
|
96
|
+
stopAudio: () => {
|
|
97
|
+
for (const source of playing) source.stop();
|
|
98
|
+
playing.clear();
|
|
99
|
+
playAt = 0;
|
|
100
|
+
},
|
|
101
|
+
brightness: (percent) => {
|
|
102
|
+
canvas.style.filter = `brightness(${Math.max(percent, 25) / 100})`;
|
|
103
|
+
},
|
|
104
|
+
reconnect: () => {
|
|
105
|
+
ws?.close();
|
|
106
|
+
},
|
|
107
|
+
// Forget the pairing: the next connection asks for a new code (the page's button, the desktop app).
|
|
108
|
+
forget: () => {
|
|
109
|
+
M._sim_forget();
|
|
110
|
+
ws?.close();
|
|
111
|
+
},
|
|
112
|
+
// A PIN key from a keyboard or the desktop app ("0"…"9", "<", "OK").
|
|
113
|
+
pinKey: (key) => {
|
|
114
|
+
withString(key, (ptr) => M._sim_pin_key(ptr));
|
|
115
|
+
wake();
|
|
116
|
+
},
|
|
117
|
+
// The desktop charm's panel opens below the notch for speech and questions, and closes after.
|
|
118
|
+
panel: (open) => {
|
|
119
|
+
charmEl.classList.toggle("open", open);
|
|
120
|
+
trace.panelOpen = open;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const M = await createCharm();
|
|
125
|
+
const glyph = 0;
|
|
126
|
+
M._sim_init(
|
|
127
|
+
W,
|
|
128
|
+
H,
|
|
129
|
+
round ? 1 : 0,
|
|
130
|
+
glyph,
|
|
131
|
+
notch ? NOTCH.width : 0,
|
|
132
|
+
notch ? NOTCH.strip : 0
|
|
133
|
+
);
|
|
134
|
+
const frame = canvas.getContext("2d").createImageData(W, H);
|
|
135
|
+
|
|
136
|
+
function withString(text, fn) {
|
|
137
|
+
const size = M.lengthBytesUTF8(text) + 1;
|
|
138
|
+
const ptr = M._malloc(size);
|
|
139
|
+
M.stringToUTF8(text, ptr, size);
|
|
140
|
+
fn(ptr);
|
|
141
|
+
M._free(ptr);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function withBytes(bytes, fn) {
|
|
145
|
+
const ptr = M._malloc(bytes.length);
|
|
146
|
+
M.HEAPU8.set(bytes, ptr);
|
|
147
|
+
fn(ptr, bytes.length);
|
|
148
|
+
M._free(ptr);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Back from sleep or a network change: the old socket may be dead without knowing it. Start again.
|
|
152
|
+
addEventListener("online", () => ws?.close());
|
|
153
|
+
|
|
154
|
+
function connect() {
|
|
155
|
+
if (!wifiOn) return;
|
|
156
|
+
const token = M.UTF8ToString(M._sim_token());
|
|
157
|
+
// The token rides as a WebSocket subprotocol: browsers can't set Authorization, and a token must
|
|
158
|
+
// never go in a URL (proxies and logs keep URLs).
|
|
159
|
+
const protocols = token
|
|
160
|
+
? ["opencharm", `opencharm.token.${token}`]
|
|
161
|
+
: ["opencharm"];
|
|
162
|
+
statusEl.textContent = "CONNECTING";
|
|
163
|
+
ws = new WebSocket(baseUrl, protocols);
|
|
164
|
+
ws.binaryType = "arraybuffer";
|
|
165
|
+
ws.onopen = () => {
|
|
166
|
+
trace.connected = true;
|
|
167
|
+
statusEl.textContent = "CONNECTED";
|
|
168
|
+
M._sim_connected();
|
|
169
|
+
};
|
|
170
|
+
ws.onmessage = (event) => {
|
|
171
|
+
if (typeof event.data === "string") {
|
|
172
|
+
const message = JSON.parse(event.data);
|
|
173
|
+
trace.messages.push(message);
|
|
174
|
+
// The desktop charm runs for days: keep the trace to the latest messages.
|
|
175
|
+
if (trace.messages.length > 500) trace.messages.shift();
|
|
176
|
+
window.charmSim.onMessage?.(message);
|
|
177
|
+
withString(event.data, (ptr) => M._sim_text(ptr));
|
|
178
|
+
wake();
|
|
179
|
+
} else {
|
|
180
|
+
trace.audioFrames += 1;
|
|
181
|
+
withBytes(new Uint8Array(event.data), (ptr, n) => M._sim_audio(ptr, n));
|
|
182
|
+
wake(400);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
ws.onclose = () => {
|
|
186
|
+
trace.connected = false;
|
|
187
|
+
statusEl.textContent = wifiOn ? "RECONNECTING" : "WI-FI OFF";
|
|
188
|
+
M._sim_disconnected();
|
|
189
|
+
setTimeout(connect, 1500);
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Sound out needs a user gesture in browsers; the first key press or tap starts it.
|
|
194
|
+
function startPlayback() {
|
|
195
|
+
playCtx ??= new AudioContext({ sampleRate: 24000 });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A running AudioContext keeps an audio thread busy even in silence: rest it between replies.
|
|
199
|
+
let speakerRest;
|
|
200
|
+
function idleSpeaker() {
|
|
201
|
+
clearTimeout(speakerRest);
|
|
202
|
+
speakerRest = setTimeout(() => {
|
|
203
|
+
if (playing.size === 0 && playCtx?.state === "running")
|
|
204
|
+
void playCtx.suspend();
|
|
205
|
+
}, 2000);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function showMic(text) {
|
|
209
|
+
statusEl.textContent = `${trace.connected ? "CONNECTED · " : ""}${text}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The microphone opens when the key goes down and closes when it comes up, so the browser (and
|
|
213
|
+
// anyone looking) only ever sees it recording while the key is held. The charm's core still decides
|
|
214
|
+
// which of those frames may leave (after a 200 ms hold).
|
|
215
|
+
async function openMic() {
|
|
216
|
+
if (mic) return;
|
|
217
|
+
const opening = {
|
|
218
|
+
stream: undefined,
|
|
219
|
+
ctx: new AudioContext({ sampleRate: 16000 }),
|
|
220
|
+
};
|
|
221
|
+
mic = opening;
|
|
222
|
+
try {
|
|
223
|
+
if (!fakeMic)
|
|
224
|
+
opening.stream = await navigator.mediaDevices.getUserMedia({
|
|
225
|
+
audio: {
|
|
226
|
+
channelCount: 1,
|
|
227
|
+
echoCancellation: true,
|
|
228
|
+
noiseSuppression: true,
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
await opening.ctx.audioWorklet.addModule("mic-worklet.js");
|
|
232
|
+
if (mic !== opening || !keyHeld) return closeMic(opening);
|
|
233
|
+
const node = new AudioWorkletNode(opening.ctx, "mic-collector");
|
|
234
|
+
node.port.onmessage = ({ data }) => {
|
|
235
|
+
if (!micOn) return;
|
|
236
|
+
const bytes = new Uint8Array(data.buffer);
|
|
237
|
+
const ptr = M._malloc(bytes.length);
|
|
238
|
+
M.HEAPU8.set(bytes, ptr);
|
|
239
|
+
M._sim_mic_pcm(ptr, data.length);
|
|
240
|
+
M._free(ptr);
|
|
241
|
+
};
|
|
242
|
+
if (fakeMic) {
|
|
243
|
+
const tone = new OscillatorNode(opening.ctx, { frequency: 330 });
|
|
244
|
+
const quiet = new GainNode(opening.ctx, { gain: 0.2 });
|
|
245
|
+
tone.connect(quiet).connect(node);
|
|
246
|
+
tone.start();
|
|
247
|
+
} else {
|
|
248
|
+
opening.ctx.createMediaStreamSource(opening.stream).connect(node);
|
|
249
|
+
}
|
|
250
|
+
trace.micReady = true;
|
|
251
|
+
trace.micOpens += 1;
|
|
252
|
+
showMic("MIC ON");
|
|
253
|
+
} catch (error) {
|
|
254
|
+
closeMic(opening);
|
|
255
|
+
statusEl.textContent = "NO MICROPHONE";
|
|
256
|
+
console.warn("OpenCharm emulator: microphone unavailable", error);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function closeMic(which = mic) {
|
|
261
|
+
if (!which) return;
|
|
262
|
+
for (const track of which.stream?.getTracks() ?? []) track.stop();
|
|
263
|
+
void which.ctx.close().catch(() => undefined);
|
|
264
|
+
if (mic === which) {
|
|
265
|
+
mic = undefined;
|
|
266
|
+
trace.micReady = false;
|
|
267
|
+
showMic("MIC OFF");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// The desktop app drives the key from its global push-to-talk shortcut.
|
|
272
|
+
window.charmKey = (down) => key(down);
|
|
273
|
+
|
|
274
|
+
function key(down) {
|
|
275
|
+
startPlayback();
|
|
276
|
+
keyHeld = down;
|
|
277
|
+
if (down) void openMic();
|
|
278
|
+
else closeMic();
|
|
279
|
+
keyEl.classList.toggle("down", down);
|
|
280
|
+
M._sim_key(down ? 1 : 0);
|
|
281
|
+
wake();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let spaceDown = false;
|
|
285
|
+
addEventListener("keydown", (e) => {
|
|
286
|
+
if (e.code !== "Space" || e.repeat || spaceDown) return;
|
|
287
|
+
e.preventDefault();
|
|
288
|
+
spaceDown = true;
|
|
289
|
+
key(true);
|
|
290
|
+
});
|
|
291
|
+
addEventListener("keyup", (e) => {
|
|
292
|
+
if (e.code !== "Space") return;
|
|
293
|
+
e.preventDefault();
|
|
294
|
+
spaceDown = false;
|
|
295
|
+
key(false);
|
|
296
|
+
});
|
|
297
|
+
keyEl.addEventListener("pointerdown", () => key(true));
|
|
298
|
+
keyEl.addEventListener("pointerup", () => key(false));
|
|
299
|
+
keyEl.addEventListener(
|
|
300
|
+
"pointerleave",
|
|
301
|
+
() => keyEl.classList.contains("down") && key(false)
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
function pointer(e, pressed) {
|
|
305
|
+
const rect = canvas.getBoundingClientRect();
|
|
306
|
+
const x = Math.round(((e.clientX - rect.left) / rect.width) * W);
|
|
307
|
+
const y = Math.round(((e.clientY - rect.top) / rect.height) * H);
|
|
308
|
+
M._sim_pointer(x, y, pressed ? 1 : 0);
|
|
309
|
+
wake();
|
|
310
|
+
}
|
|
311
|
+
canvas.addEventListener("pointerdown", (e) => {
|
|
312
|
+
startPlayback();
|
|
313
|
+
canvas.setPointerCapture(e.pointerId);
|
|
314
|
+
pointer(e, true);
|
|
315
|
+
});
|
|
316
|
+
canvas.addEventListener("pointermove", (e) => e.buttons && pointer(e, true));
|
|
317
|
+
canvas.addEventListener("pointerup", (e) => pointer(e, false));
|
|
318
|
+
|
|
319
|
+
document.getElementById("wifi").addEventListener("click", (e) => {
|
|
320
|
+
wifiOn = !wifiOn;
|
|
321
|
+
e.target.classList.toggle("on", !wifiOn);
|
|
322
|
+
e.target.textContent = wifiOn ? "Drop Wi-Fi" : "Wi-Fi is off";
|
|
323
|
+
if (wifiOn) connect();
|
|
324
|
+
else ws?.close();
|
|
325
|
+
});
|
|
326
|
+
// Square → round → notch → square.
|
|
327
|
+
const NEXT = { square: "round", round: "notch", notch: "square" };
|
|
328
|
+
const LABEL = {
|
|
329
|
+
square: "Square screen",
|
|
330
|
+
round: "Round screen",
|
|
331
|
+
notch: "Mac notch",
|
|
332
|
+
};
|
|
333
|
+
document.getElementById("shape").textContent = `Try: ${LABEL[NEXT[shape]]}`;
|
|
334
|
+
document.getElementById("shape").addEventListener("click", () => {
|
|
335
|
+
params.delete("round");
|
|
336
|
+
params.set("shape", NEXT[shape]);
|
|
337
|
+
location.search = params.toString();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// The PIN can be typed on the keyboard too (the notch has no room for a pad).
|
|
341
|
+
addEventListener("keydown", (e) => {
|
|
342
|
+
const key = /^[0-9]$/.test(e.key)
|
|
343
|
+
? e.key
|
|
344
|
+
: e.key === "Backspace"
|
|
345
|
+
? "<"
|
|
346
|
+
: e.key === "Enter"
|
|
347
|
+
? "OK"
|
|
348
|
+
: null;
|
|
349
|
+
if (key) withString(key, (ptr) => M._sim_pin_key(ptr));
|
|
350
|
+
});
|
|
351
|
+
document.getElementById("forget").addEventListener("click", () => {
|
|
352
|
+
M._sim_forget();
|
|
353
|
+
ws?.close();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// The charm is always on screen, so it must cost next to nothing at rest. The core only redraws what
|
|
357
|
+
// moved; this loop copies only that box, ticks at 60 Hz while something animates and at 10 Hz at
|
|
358
|
+
// rest (a breath step or a blink now and then), and wakes at once for input or a message.
|
|
359
|
+
const ctx2d = canvas.getContext("2d");
|
|
360
|
+
const ACTIVE_MS = 16;
|
|
361
|
+
const REST_MS = 100;
|
|
362
|
+
let timer;
|
|
363
|
+
let busyUntil = 0;
|
|
364
|
+
let drewLast = false;
|
|
365
|
+
|
|
366
|
+
function blit() {
|
|
367
|
+
const box = M._sim_frame_box() >> 2;
|
|
368
|
+
const [x1, y1, x2, y2] = M.HEAP32.subarray(box, box + 4);
|
|
369
|
+
const base = M._sim_frame();
|
|
370
|
+
for (let y = y1; y <= y2; y++) {
|
|
371
|
+
const from = base + (y * W + x1) * 4;
|
|
372
|
+
frame.data.set(
|
|
373
|
+
M.HEAPU8.subarray(from, from + (x2 - x1 + 1) * 4),
|
|
374
|
+
(y * W + x1) * 4
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
ctx2d.putImageData(frame, 0, 0, x1, y1, x2 - x1 + 1, y2 - y1 + 1);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function loop() {
|
|
381
|
+
const now = performance.now();
|
|
382
|
+
M._sim_tick(Math.floor(now));
|
|
383
|
+
const drew = M._sim_frame_dirty() === 1;
|
|
384
|
+
if (drew) blit();
|
|
385
|
+
// Two frames in a row means an animation is running: keep up with it.
|
|
386
|
+
const animating = drew && drewLast;
|
|
387
|
+
drewLast = drew;
|
|
388
|
+
timer = setTimeout(
|
|
389
|
+
loop,
|
|
390
|
+
animating || now < busyUntil || keyHeld ? ACTIVE_MS : REST_MS
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Input and messages: answer within a frame, and stay smooth for a moment after.
|
|
395
|
+
function wake(ms = 1500) {
|
|
396
|
+
busyUntil = Math.max(busyUntil, performance.now() + ms);
|
|
397
|
+
clearTimeout(timer);
|
|
398
|
+
timer = setTimeout(loop, 0);
|
|
399
|
+
}
|
|
400
|
+
window.charmSim.wake = wake;
|
|
401
|
+
loop();
|
|
402
|
+
connect();
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 OpenCharm contributors
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|