@kuralle-syrinx/server-workers 2.1.1 → 3.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/.dev.vars.example +17 -0
- package/package.json +12 -9
- package/src/live-realtime-session.test.ts +22 -23
- package/src/live-realtime-session.ts +37 -40
- package/src/live-session.test.ts +36 -31
- package/src/live-session.ts +52 -53
- package/src/worker-realtime.ts +16 -68
- package/src/worker-runtime.test.ts +144 -0
- package/src/worker.ts +81 -94
- package/wrangler.jsonc +8 -0
- package/src/alarm-scheduler.test.ts +0 -44
- package/src/alarm-scheduler.ts +0 -67
- package/src/durable-session-store.test.ts +0 -60
- package/src/durable-session-store.ts +0 -148
- package/src/r2-recorder.test.ts +0 -100
- package/src/r2-recorder.ts +0 -218
- package/src/test-storage.ts +0 -80
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import type {
|
|
4
|
-
ManagedSession,
|
|
5
|
-
ManagedSessionLease,
|
|
6
|
-
SessionStore,
|
|
7
|
-
} from "@kuralle-syrinx/server-websocket/session-store";
|
|
8
|
-
import type { DurableObjectAlarmScheduler } from "./alarm-scheduler.js";
|
|
9
|
-
|
|
10
|
-
type SqlCursor<T> = Iterable<T>;
|
|
11
|
-
|
|
12
|
-
interface SqlStorage {
|
|
13
|
-
exec(query: string, ...bindings: unknown[]): SqlCursor<Record<string, unknown>>;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface DurableSessionStorage {
|
|
17
|
-
readonly sql: SqlStorage;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface SessionRow {
|
|
21
|
-
id: string;
|
|
22
|
-
current_context_id: string;
|
|
23
|
-
last_sequence: number | null;
|
|
24
|
-
retained_until_ms: number;
|
|
25
|
-
connection_count: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export class DurableObjectSessionStore implements SessionStore {
|
|
29
|
-
private readonly sessions = new Map<string, ManagedSession>();
|
|
30
|
-
private readonly pendingLeases = new Map<string, Promise<ManagedSession>>();
|
|
31
|
-
|
|
32
|
-
constructor(
|
|
33
|
-
private readonly storage: DurableSessionStorage,
|
|
34
|
-
private readonly scheduler: DurableObjectAlarmScheduler,
|
|
35
|
-
) {
|
|
36
|
-
this.storage.sql.exec(
|
|
37
|
-
`CREATE TABLE IF NOT EXISTS sessions (
|
|
38
|
-
id TEXT PRIMARY KEY,
|
|
39
|
-
current_context_id TEXT NOT NULL,
|
|
40
|
-
last_sequence INTEGER,
|
|
41
|
-
retained_until_ms INTEGER NOT NULL DEFAULT 0,
|
|
42
|
-
connection_count INTEGER NOT NULL DEFAULT 0,
|
|
43
|
-
updated_at_ms INTEGER NOT NULL
|
|
44
|
-
)`,
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async lease(sessionId: string, create: () => Promise<ManagedSession>): Promise<ManagedSessionLease> {
|
|
49
|
-
const existing = this.sessions.get(sessionId);
|
|
50
|
-
if (existing) {
|
|
51
|
-
this.attachConnection(existing);
|
|
52
|
-
return { managed: existing, resumed: true };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
let pending = this.pendingLeases.get(sessionId);
|
|
56
|
-
if (!pending) {
|
|
57
|
-
pending = create().then((managed) => {
|
|
58
|
-
const row = this.row(sessionId);
|
|
59
|
-
if (row) {
|
|
60
|
-
managed.currentContextId = row.current_context_id;
|
|
61
|
-
managed.inputSequence.lastSequence = row.last_sequence;
|
|
62
|
-
}
|
|
63
|
-
this.sessions.set(sessionId, managed);
|
|
64
|
-
this.persist(managed, 0);
|
|
65
|
-
return managed;
|
|
66
|
-
}).finally(() => {
|
|
67
|
-
this.pendingLeases.delete(sessionId);
|
|
68
|
-
});
|
|
69
|
-
this.pendingLeases.set(sessionId, pending);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const row = this.row(sessionId);
|
|
73
|
-
const managed = await pending;
|
|
74
|
-
this.attachConnection(managed);
|
|
75
|
-
return { managed, resumed: Boolean(row && row.retained_until_ms > Date.now()) };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async release(sessionId: string, retainMs: number): Promise<void> {
|
|
79
|
-
const managed = this.sessions.get(sessionId);
|
|
80
|
-
if (!managed) return;
|
|
81
|
-
if (managed.connectionCount > 0) return;
|
|
82
|
-
|
|
83
|
-
if (retainMs <= 0) {
|
|
84
|
-
this.sessions.delete(sessionId);
|
|
85
|
-
this.storage.sql.exec("DELETE FROM sessions WHERE id = ?", sessionId);
|
|
86
|
-
await managed.session.close().catch(() => undefined);
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const retainedUntilMs = Date.now() + retainMs;
|
|
91
|
-
this.persist(managed, retainedUntilMs);
|
|
92
|
-
this.scheduler.schedule(`session.retain:${sessionId}`, retainMs, async () => {
|
|
93
|
-
const current = this.sessions.get(sessionId);
|
|
94
|
-
if (!current || current.connectionCount > 0) return;
|
|
95
|
-
this.sessions.delete(sessionId);
|
|
96
|
-
this.storage.sql.exec("DELETE FROM sessions WHERE id = ?", sessionId);
|
|
97
|
-
await current.session.close().catch(() => undefined);
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
update(sessionId: string, mutate: (managed: ManagedSession) => void): void {
|
|
102
|
-
const managed = this.sessions.get(sessionId);
|
|
103
|
-
if (!managed) return;
|
|
104
|
-
mutate(managed);
|
|
105
|
-
this.persist(managed, 0);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
async get(sessionId: string): Promise<ManagedSession | null> {
|
|
109
|
-
return this.sessions.get(sessionId) ?? null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async listAll(): Promise<readonly ManagedSession[]> {
|
|
113
|
-
return [...this.sessions.values()];
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async clear(): Promise<void> {
|
|
117
|
-
for (const managed of this.sessions.values()) {
|
|
118
|
-
await managed.session.close().catch(() => undefined);
|
|
119
|
-
}
|
|
120
|
-
this.sessions.clear();
|
|
121
|
-
this.storage.sql.exec("DELETE FROM sessions");
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
private attachConnection(managed: ManagedSession): void {
|
|
125
|
-
this.scheduler.cancel(`session.retain:${managed.id}`);
|
|
126
|
-
managed.connectionCount += 1;
|
|
127
|
-
this.persist(managed, 0);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
private persist(managed: ManagedSession, retainedUntilMs: number): void {
|
|
131
|
-
this.storage.sql.exec(
|
|
132
|
-
`INSERT OR REPLACE INTO sessions
|
|
133
|
-
(id, current_context_id, last_sequence, retained_until_ms, connection_count, updated_at_ms)
|
|
134
|
-
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
135
|
-
managed.id,
|
|
136
|
-
managed.currentContextId,
|
|
137
|
-
managed.inputSequence.lastSequence,
|
|
138
|
-
retainedUntilMs,
|
|
139
|
-
managed.connectionCount,
|
|
140
|
-
Date.now(),
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
private row(sessionId: string): SessionRow | null {
|
|
145
|
-
const [row] = [...this.storage.sql.exec("SELECT * FROM sessions WHERE id = ?", sessionId)] as unknown as SessionRow[];
|
|
146
|
-
return row ?? null;
|
|
147
|
-
}
|
|
148
|
-
}
|
package/src/r2-recorder.test.ts
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { R2EdgeRecorder } from "./r2-recorder.js";
|
|
5
|
-
|
|
6
|
-
interface PutCall {
|
|
7
|
-
key: string;
|
|
8
|
-
body: Uint8Array | string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function fakeBucket() {
|
|
12
|
-
const puts: PutCall[] = [];
|
|
13
|
-
const bucket = {
|
|
14
|
-
async put(key: string, body: Uint8Array | string) {
|
|
15
|
-
puts.push({ key, body });
|
|
16
|
-
return {} as unknown;
|
|
17
|
-
},
|
|
18
|
-
} as unknown as R2Bucket;
|
|
19
|
-
return { bucket, puts };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function asString(body: Uint8Array | string): string {
|
|
23
|
-
return typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function wavChannels(body: Uint8Array | string): number {
|
|
27
|
-
const wav = body as Uint8Array;
|
|
28
|
-
return new DataView(wav.buffer, wav.byteOffset, wav.byteLength).getUint16(22, true);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
describe("R2EdgeRecorder", () => {
|
|
32
|
-
it("writes a stereo conversation.wav plus user/assistant stems and manifest", async () => {
|
|
33
|
-
const { bucket, puts } = fakeBucket();
|
|
34
|
-
const rec = new R2EdgeRecorder({ bucket, sessionId: "s1", startedAtMs: 1000, now: () => 1000 });
|
|
35
|
-
|
|
36
|
-
rec.onUserAudio("c1", new Uint8Array(640), 16000);
|
|
37
|
-
rec.onAssistantAudio("c1", new Uint8Array(320), 24000);
|
|
38
|
-
await rec.finalize({ sessionId: "s1", closedAtMs: 2000 });
|
|
39
|
-
|
|
40
|
-
const keys = puts.map((p) => p.key).sort();
|
|
41
|
-
expect(keys).toEqual([
|
|
42
|
-
"recordings/s1/1000/assistant.wav",
|
|
43
|
-
"recordings/s1/1000/conversation.wav",
|
|
44
|
-
"recordings/s1/1000/manifest.json",
|
|
45
|
-
"recordings/s1/1000/user.wav",
|
|
46
|
-
]);
|
|
47
|
-
|
|
48
|
-
const conversation = puts.find((p) => p.key.endsWith("conversation.wav"))!.body;
|
|
49
|
-
expect(asString((conversation as Uint8Array).subarray(0, 4))).toBe("RIFF");
|
|
50
|
-
expect(wavChannels(conversation)).toBe(2); // stereo: user L / assistant R
|
|
51
|
-
expect(wavChannels(puts.find((p) => p.key.endsWith("user.wav"))!.body)).toBe(1);
|
|
52
|
-
|
|
53
|
-
const manifest = JSON.parse(asString(puts.find((p) => p.key.endsWith("manifest.json"))!.body)) as {
|
|
54
|
-
conversation: { channels: number };
|
|
55
|
-
};
|
|
56
|
-
expect(manifest.conversation.channels).toBe(2);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("time-aligns the assistant after the user instead of stacking at 0", async () => {
|
|
60
|
-
const { bucket, puts } = fakeBucket();
|
|
61
|
-
let now = 0;
|
|
62
|
-
const rec = new R2EdgeRecorder({ bucket, sessionId: "t", startedAtMs: 0, now: () => now });
|
|
63
|
-
|
|
64
|
-
now = 0;
|
|
65
|
-
rec.onUserAudio("c", new Uint8Array(640), 16000); // 20ms of user at t=0
|
|
66
|
-
now = 1000;
|
|
67
|
-
rec.onAssistantAudio("c", new Uint8Array(320), 16000); // assistant starts at t=1000ms
|
|
68
|
-
await rec.finalize({ sessionId: "t", closedAtMs: 1100 });
|
|
69
|
-
|
|
70
|
-
const manifest = JSON.parse(asString(puts.find((p) => p.key.endsWith("manifest.json"))!.body)) as {
|
|
71
|
-
conversation: { durationMs: number };
|
|
72
|
-
assistant: { durationMs: number };
|
|
73
|
-
};
|
|
74
|
-
// Assistant anchored at ~1000ms, so both the assistant stem and the merged
|
|
75
|
-
// conversation run ~1010ms — not the ~20ms they'd be if stacked at offset 0.
|
|
76
|
-
expect(manifest.assistant.durationMs).toBe(1010);
|
|
77
|
-
expect(manifest.conversation.durationMs).toBe(1010);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("does not write anything when no audio was captured", async () => {
|
|
81
|
-
const { bucket, puts } = fakeBucket();
|
|
82
|
-
const rec = new R2EdgeRecorder({ bucket, sessionId: "empty", startedAtMs: 1 });
|
|
83
|
-
await rec.finalize({ sessionId: "empty", closedAtMs: 2 });
|
|
84
|
-
expect(puts).toHaveLength(0);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("flags truncation past the per-stream cap instead of buffering unbounded", async () => {
|
|
88
|
-
const { bucket, puts } = fakeBucket();
|
|
89
|
-
const rec = new R2EdgeRecorder({ bucket, sessionId: "big", startedAtMs: 1, maxBytesPerStream: 1000, now: () => 1 });
|
|
90
|
-
rec.onUserAudio("c", new Uint8Array(800), 16000);
|
|
91
|
-
rec.onUserAudio("c", new Uint8Array(800), 16000); // would exceed 1000 -> dropped
|
|
92
|
-
await rec.finalize({ sessionId: "big", closedAtMs: 2 });
|
|
93
|
-
|
|
94
|
-
const manifest = JSON.parse(asString(puts.find((p) => p.key.endsWith("manifest.json"))!.body)) as {
|
|
95
|
-
user: { byteLength: number; truncated: boolean };
|
|
96
|
-
};
|
|
97
|
-
expect(manifest.user.byteLength).toBe(800);
|
|
98
|
-
expect(manifest.user.truncated).toBe(true);
|
|
99
|
-
});
|
|
100
|
-
});
|
package/src/r2-recorder.ts
DELETED
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
//
|
|
3
|
-
// R2-backed implementation of the transport EdgeRecorder. Taps inbound caller
|
|
4
|
-
// audio and outbound TTS audio and, on call end, writes to an R2 bucket:
|
|
5
|
-
// - conversation.wav : the FULL conversation, one stereo file (user = left,
|
|
6
|
-
// assistant = right), time-aligned by wall-clock so the
|
|
7
|
-
// assistant sits after the user instead of stacked at 0.
|
|
8
|
-
// - user.wav / assistant.wav : the per-speaker stems (useful for diarization).
|
|
9
|
-
// - manifest.json : durations / byte lengths / truncation flags.
|
|
10
|
-
// Mirrors the Node `voice-recorder` conversation-track approach (wall-clock byte
|
|
11
|
-
// offsets + stereo interleave) but stays edge-safe (no node:fs). Cloudflare's own
|
|
12
|
-
// withVoice persists transcripts to SQLite, not raw audio — this is the additive
|
|
13
|
-
// piece. Buffered (memory-capped) and flushed once on finalize, off the hot path.
|
|
14
|
-
|
|
15
|
-
import type { EdgeRecorder } from "@kuralle-syrinx/server-websocket/edge";
|
|
16
|
-
|
|
17
|
-
export interface R2EdgeRecorderOptions {
|
|
18
|
-
readonly bucket: R2Bucket;
|
|
19
|
-
readonly sessionId: string;
|
|
20
|
-
readonly startedAtMs: number;
|
|
21
|
-
/** Object key prefix. Default "recordings". */
|
|
22
|
-
readonly keyPrefix?: string;
|
|
23
|
-
/** Per-stream memory cap; recording past it is dropped and flagged. Default 64 MiB. */
|
|
24
|
-
readonly maxBytesPerStream?: number;
|
|
25
|
-
/** Injectable clock (test seam). Defaults to Date.now. */
|
|
26
|
-
readonly now?: () => number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const DEFAULT_MAX_BYTES_PER_STREAM = 64 * 1024 * 1024;
|
|
30
|
-
|
|
31
|
-
interface AudioChunk {
|
|
32
|
-
offsetBytes: number;
|
|
33
|
-
data: Uint8Array;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
interface StreamBuffer {
|
|
37
|
-
chunks: AudioChunk[];
|
|
38
|
-
cursorBytes: number; // end of the last placed chunk on the wall-clock timeline
|
|
39
|
-
dataBytes: number; // actual audio bytes captured (for the cap)
|
|
40
|
-
sampleRateHz: number;
|
|
41
|
-
truncated: boolean;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function emptyStream(): StreamBuffer {
|
|
45
|
-
return { chunks: [], cursorBytes: 0, dataBytes: 0, sampleRateHz: 16000, truncated: false };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export class R2EdgeRecorder implements EdgeRecorder {
|
|
49
|
-
readonly #user = emptyStream();
|
|
50
|
-
readonly #assistant = emptyStream();
|
|
51
|
-
readonly #maxBytes: number;
|
|
52
|
-
readonly #now: () => number;
|
|
53
|
-
#finalized = false;
|
|
54
|
-
|
|
55
|
-
constructor(private readonly opts: R2EdgeRecorderOptions) {
|
|
56
|
-
this.#maxBytes = opts.maxBytesPerStream ?? DEFAULT_MAX_BYTES_PER_STREAM;
|
|
57
|
-
this.#now = opts.now ?? Date.now;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
onUserAudio(_contextId: string, audio: Uint8Array, sampleRateHz: number): void {
|
|
61
|
-
this.#append(this.#user, audio, sampleRateHz);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
onAssistantAudio(_contextId: string, audio: Uint8Array, sampleRateHz: number): void {
|
|
65
|
-
this.#append(this.#assistant, audio, sampleRateHz);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async finalize(meta: { sessionId: string; closedAtMs: number }): Promise<void> {
|
|
69
|
-
if (this.#finalized) return;
|
|
70
|
-
this.#finalized = true;
|
|
71
|
-
if (this.#user.dataBytes === 0 && this.#assistant.dataBytes === 0) return;
|
|
72
|
-
|
|
73
|
-
const prefix = `${this.opts.keyPrefix ?? "recordings"}/${this.opts.sessionId}/${this.opts.startedAtMs}`;
|
|
74
|
-
const rate = this.#user.sampleRateHz; // conversation timeline runs at the user (input) rate
|
|
75
|
-
|
|
76
|
-
const userPcm = gapFill(this.#user);
|
|
77
|
-
const assistantRaw = gapFill(this.#assistant);
|
|
78
|
-
const assistantPcm =
|
|
79
|
-
this.#assistant.sampleRateHz === rate
|
|
80
|
-
? assistantRaw
|
|
81
|
-
: resamplePcm16(assistantRaw, this.#assistant.sampleRateHz, rate);
|
|
82
|
-
const conversation = interleaveStereo(userPcm, assistantPcm);
|
|
83
|
-
|
|
84
|
-
const manifest = {
|
|
85
|
-
schemaVersion: 1 as const,
|
|
86
|
-
sessionId: meta.sessionId,
|
|
87
|
-
startedAtMs: this.opts.startedAtMs,
|
|
88
|
-
closedAtMs: meta.closedAtMs,
|
|
89
|
-
conversation: {
|
|
90
|
-
path: `${prefix}/conversation.wav`,
|
|
91
|
-
sampleRateHz: rate,
|
|
92
|
-
channels: 2 as const,
|
|
93
|
-
encoding: "pcm_s16le" as const,
|
|
94
|
-
byteLength: conversation.byteLength,
|
|
95
|
-
durationMs: rate > 0 ? Math.round((conversation.byteLength / 4 / rate) * 1000) : 0,
|
|
96
|
-
},
|
|
97
|
-
user: this.#describe(this.#user, `${prefix}/user.wav`),
|
|
98
|
-
assistant: this.#describe(this.#assistant, `${prefix}/assistant.wav`),
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
await Promise.all([
|
|
102
|
-
this.opts.bucket.put(`${prefix}/conversation.wav`, pcm16ToWav(conversation, rate, 2), {
|
|
103
|
-
httpMetadata: { contentType: "audio/wav" },
|
|
104
|
-
}),
|
|
105
|
-
this.opts.bucket.put(`${prefix}/user.wav`, pcm16ToWav(userPcm, this.#user.sampleRateHz, 1), {
|
|
106
|
-
httpMetadata: { contentType: "audio/wav" },
|
|
107
|
-
}),
|
|
108
|
-
this.opts.bucket.put(`${prefix}/assistant.wav`, pcm16ToWav(assistantRaw, this.#assistant.sampleRateHz, 1), {
|
|
109
|
-
httpMetadata: { contentType: "audio/wav" },
|
|
110
|
-
}),
|
|
111
|
-
this.opts.bucket.put(`${prefix}/manifest.json`, JSON.stringify(manifest, null, 2), {
|
|
112
|
-
httpMetadata: { contentType: "application/json" },
|
|
113
|
-
}),
|
|
114
|
-
]);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
#append(buf: StreamBuffer, audio: Uint8Array, sampleRateHz: number): void {
|
|
118
|
-
buf.sampleRateHz = sampleRateHz;
|
|
119
|
-
if (buf.dataBytes + audio.byteLength > this.#maxBytes) {
|
|
120
|
-
buf.truncated = true;
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
// Anchor each chunk at its wall-clock position so the two speakers line up on a
|
|
124
|
-
// shared timeline; never overlap the previous chunk in the same stream.
|
|
125
|
-
const wallOffset = this.#wallOffsetBytes(sampleRateHz);
|
|
126
|
-
const offsetBytes = Math.max(buf.cursorBytes, wallOffset);
|
|
127
|
-
buf.chunks.push({ offsetBytes, data: audio.slice() });
|
|
128
|
-
buf.cursorBytes = offsetBytes + audio.byteLength;
|
|
129
|
-
buf.dataBytes += audio.byteLength;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
#wallOffsetBytes(sampleRateHz: number): number {
|
|
133
|
-
const elapsedMs = Math.max(0, this.#now() - this.opts.startedAtMs);
|
|
134
|
-
const bytes = Math.floor((elapsedMs * sampleRateHz * 2) / 1000);
|
|
135
|
-
return bytes - (bytes % 2);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
#describe(buf: StreamBuffer, path: string) {
|
|
139
|
-
return {
|
|
140
|
-
path,
|
|
141
|
-
sampleRateHz: buf.sampleRateHz,
|
|
142
|
-
encoding: "pcm_s16le" as const,
|
|
143
|
-
channels: 1 as const,
|
|
144
|
-
byteLength: buf.cursorBytes,
|
|
145
|
-
durationMs: buf.sampleRateHz > 0 ? Math.round((buf.cursorBytes / 2 / buf.sampleRateHz) * 1000) : 0,
|
|
146
|
-
truncated: buf.truncated,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Lay chunks onto a silence-filled mono timeline at their wall-clock offsets. */
|
|
152
|
-
function gapFill(buf: StreamBuffer): Uint8Array {
|
|
153
|
-
const out = new Uint8Array(buf.cursorBytes); // zero = PCM16 silence
|
|
154
|
-
for (const chunk of buf.chunks) out.set(chunk.data, chunk.offsetBytes);
|
|
155
|
-
return out;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Interleave two mono PCM16 streams into stereo (left, right); pad the short one. */
|
|
159
|
-
function interleaveStereo(left: Uint8Array, right: Uint8Array): Uint8Array {
|
|
160
|
-
const leftSamples = left.byteLength >> 1;
|
|
161
|
-
const rightSamples = right.byteLength >> 1;
|
|
162
|
-
const frames = Math.max(leftSamples, rightSamples);
|
|
163
|
-
const out = new Uint8Array(frames * 4);
|
|
164
|
-
const lv = new DataView(left.buffer, left.byteOffset, left.byteLength);
|
|
165
|
-
const rv = new DataView(right.buffer, right.byteOffset, right.byteLength);
|
|
166
|
-
const ov = new DataView(out.buffer);
|
|
167
|
-
for (let i = 0; i < frames; i += 1) {
|
|
168
|
-
ov.setInt16(i * 4, i < leftSamples ? lv.getInt16(i * 2, true) : 0, true);
|
|
169
|
-
ov.setInt16(i * 4 + 2, i < rightSamples ? rv.getInt16(i * 2, true) : 0, true);
|
|
170
|
-
}
|
|
171
|
-
return out;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function resamplePcm16(pcm: Uint8Array, fromHz: number, toHz: number): Uint8Array {
|
|
175
|
-
if (fromHz === toHz || pcm.byteLength === 0) return pcm;
|
|
176
|
-
const src = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
|
|
177
|
-
const inSamples = pcm.byteLength >> 1;
|
|
178
|
-
const outSamples = Math.max(1, Math.round((inSamples * toHz) / fromHz));
|
|
179
|
-
const out = new Uint8Array(outSamples * 2);
|
|
180
|
-
const ov = new DataView(out.buffer);
|
|
181
|
-
const ratio = (inSamples - 1) / Math.max(1, outSamples - 1);
|
|
182
|
-
for (let i = 0; i < outSamples; i += 1) {
|
|
183
|
-
const x = i * ratio;
|
|
184
|
-
const i0 = Math.floor(x);
|
|
185
|
-
const i1 = Math.min(inSamples - 1, i0 + 1);
|
|
186
|
-
const frac = x - i0;
|
|
187
|
-
const s = src.getInt16(i0 * 2, true) * (1 - frac) + src.getInt16(i1 * 2, true) * frac;
|
|
188
|
-
ov.setInt16(i * 2, Math.round(s), true);
|
|
189
|
-
}
|
|
190
|
-
return out;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Wrap raw PCM16 little-endian bytes in a canonical 44-byte WAV header. */
|
|
194
|
-
function pcm16ToWav(pcm: Uint8Array, sampleRateHz: number, channels: number): Uint8Array {
|
|
195
|
-
const blockAlign = channels * 2; // 16-bit
|
|
196
|
-
const byteRate = sampleRateHz * blockAlign;
|
|
197
|
-
const out = new Uint8Array(44 + pcm.byteLength);
|
|
198
|
-
const view = new DataView(out.buffer);
|
|
199
|
-
writeAscii(view, 0, "RIFF");
|
|
200
|
-
view.setUint32(4, 36 + pcm.byteLength, true);
|
|
201
|
-
writeAscii(view, 8, "WAVE");
|
|
202
|
-
writeAscii(view, 12, "fmt ");
|
|
203
|
-
view.setUint32(16, 16, true);
|
|
204
|
-
view.setUint16(20, 1, true); // PCM
|
|
205
|
-
view.setUint16(22, channels, true);
|
|
206
|
-
view.setUint32(24, sampleRateHz, true);
|
|
207
|
-
view.setUint32(28, byteRate, true);
|
|
208
|
-
view.setUint16(32, blockAlign, true);
|
|
209
|
-
view.setUint16(34, 16, true);
|
|
210
|
-
writeAscii(view, 36, "data");
|
|
211
|
-
view.setUint32(40, pcm.byteLength, true);
|
|
212
|
-
out.set(pcm, 44);
|
|
213
|
-
return out;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function writeAscii(view: DataView, offset: number, text: string): void {
|
|
217
|
-
for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
|
|
218
|
-
}
|
package/src/test-storage.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
interface SessionRow {
|
|
4
|
-
id: string;
|
|
5
|
-
current_context_id: string;
|
|
6
|
-
last_sequence: number | null;
|
|
7
|
-
retained_until_ms: number;
|
|
8
|
-
connection_count: number;
|
|
9
|
-
updated_at_ms: number;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface TaskRow {
|
|
13
|
-
key: string;
|
|
14
|
-
deadline_ms: number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export class MemoryDurableStorage {
|
|
18
|
-
readonly sessions = new Map<string, SessionRow>();
|
|
19
|
-
readonly tasks = new Map<string, TaskRow>();
|
|
20
|
-
alarm: number | null = null;
|
|
21
|
-
|
|
22
|
-
readonly sql = {
|
|
23
|
-
exec: (query: string, ...bindings: unknown[]): Iterable<Record<string, unknown>> => {
|
|
24
|
-
const normalized = query.replace(/\s+/g, " ").trim();
|
|
25
|
-
if (normalized.startsWith("CREATE TABLE")) return [];
|
|
26
|
-
if (normalized.startsWith("INSERT OR REPLACE INTO scheduled_tasks")) {
|
|
27
|
-
const key = String(bindings[0]);
|
|
28
|
-
this.tasks.set(key, { key, deadline_ms: Number(bindings[1]) });
|
|
29
|
-
return [];
|
|
30
|
-
}
|
|
31
|
-
if (normalized.startsWith("DELETE FROM scheduled_tasks WHERE key")) {
|
|
32
|
-
this.tasks.delete(String(bindings[0]));
|
|
33
|
-
return [];
|
|
34
|
-
}
|
|
35
|
-
if (normalized.startsWith("SELECT key FROM scheduled_tasks")) {
|
|
36
|
-
const now = Number(bindings[0]);
|
|
37
|
-
return [...this.tasks.values()]
|
|
38
|
-
.filter((row) => row.deadline_ms <= now)
|
|
39
|
-
.sort((a, b) => a.deadline_ms - b.deadline_ms) as unknown as Record<string, unknown>[];
|
|
40
|
-
}
|
|
41
|
-
if (normalized.startsWith("SELECT deadline_ms FROM scheduled_tasks")) {
|
|
42
|
-
const next = [...this.tasks.values()].sort((a, b) => a.deadline_ms - b.deadline_ms)[0];
|
|
43
|
-
return (next ? [{ deadline_ms: next.deadline_ms }] : []) as Record<string, unknown>[];
|
|
44
|
-
}
|
|
45
|
-
if (normalized.startsWith("INSERT OR REPLACE INTO sessions")) {
|
|
46
|
-
const row: SessionRow = {
|
|
47
|
-
id: String(bindings[0]),
|
|
48
|
-
current_context_id: String(bindings[1]),
|
|
49
|
-
last_sequence: bindings[2] === null ? null : Number(bindings[2]),
|
|
50
|
-
retained_until_ms: Number(bindings[3]),
|
|
51
|
-
connection_count: Number(bindings[4]),
|
|
52
|
-
updated_at_ms: Number(bindings[5]),
|
|
53
|
-
};
|
|
54
|
-
this.sessions.set(row.id, row);
|
|
55
|
-
return [];
|
|
56
|
-
}
|
|
57
|
-
if (normalized.startsWith("DELETE FROM sessions WHERE id")) {
|
|
58
|
-
this.sessions.delete(String(bindings[0]));
|
|
59
|
-
return [];
|
|
60
|
-
}
|
|
61
|
-
if (normalized.startsWith("DELETE FROM sessions")) {
|
|
62
|
-
this.sessions.clear();
|
|
63
|
-
return [];
|
|
64
|
-
}
|
|
65
|
-
if (normalized.startsWith("SELECT * FROM sessions WHERE id")) {
|
|
66
|
-
const row = this.sessions.get(String(bindings[0]));
|
|
67
|
-
return (row ? [row] : []) as unknown as Record<string, unknown>[];
|
|
68
|
-
}
|
|
69
|
-
throw new Error(`Unsupported test SQL: ${normalized}`);
|
|
70
|
-
},
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
async setAlarm(scheduledTime: number | Date): Promise<void> {
|
|
74
|
-
this.alarm = scheduledTime instanceof Date ? scheduledTime.getTime() : scheduledTime;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async deleteAlarm(): Promise<void> {
|
|
78
|
-
this.alarm = null;
|
|
79
|
-
}
|
|
80
|
-
}
|