@marktiderman/genesis-chat 0.1.1 → 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/README.md +379 -13
- package/dist/chunk-A4YJX7KT.js +544 -0
- package/dist/chunk-A4YJX7KT.js.map +1 -0
- package/dist/chunk-AZS3HNLM.js +30 -0
- package/dist/chunk-AZS3HNLM.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-GBJ3YI4E.js +966 -0
- package/dist/chunk-GBJ3YI4E.js.map +1 -0
- package/dist/chunk-JU2FAPBI.js +609 -0
- package/dist/chunk-JU2FAPBI.js.map +1 -0
- package/dist/chunk-VEPZROQB.js +187 -0
- package/dist/chunk-VEPZROQB.js.map +1 -0
- package/dist/{client-BtdALFCx.d.ts → client-DUIG1hum.d.ts} +187 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1939 -1
- package/dist/native.js +5023 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B9NAWQ58.d.ts +219 -0
- package/dist/push.d.ts +206 -0
- package/dist/push.js +3 -0
- package/dist/push.js.map +1 -0
- package/dist/react.d.ts +176 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +551 -21
- package/dist/server.js +976 -34
- package/dist/server.js.map +1 -1
- package/dist/thread-oPra7pkW.d.ts +57 -0
- package/dist/web.d.ts +335 -12
- package/dist/web.js +1784 -64
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/0003_genesis_chat_search_threads.sql +85 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
- package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
- package/package.json +78 -6
- package/src/__tests__/coaching-core.test.ts +334 -0
- package/src/__tests__/coaching-react.test.tsx +319 -0
- package/src/__tests__/coaching-web.test.tsx +521 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/fake-xhr.ts +54 -0
- package/src/__tests__/outbox-hosted.test.ts +269 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-279.test.ts +41 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +405 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fake-video.test.ts +81 -0
- package/src/__tests__/server-fakes.ts +96 -0
- package/src/__tests__/server-integrations.test.ts +247 -0
- package/src/__tests__/server-providers.test.ts +246 -0
- package/src/__tests__/server-runner.test.ts +277 -0
- package/src/__tests__/server-safety.test.ts +303 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +349 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +783 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +635 -0
- package/src/core/thread.ts +171 -0
- package/src/core/transfer.ts +71 -0
- package/src/core/types.ts +328 -0
- package/src/index.ts +14 -0
- package/src/native/__tests__/auto-retry.test.tsx +128 -0
- package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
- package/src/native/__tests__/composer-state.test.ts +188 -0
- package/src/native/__tests__/composer.test.tsx +133 -0
- package/src/native/__tests__/emoji.test.tsx +254 -0
- package/src/native/__tests__/inbox-screen.test.tsx +48 -0
- package/src/native/__tests__/message-bubble.test.tsx +127 -0
- package/src/native/__tests__/organise-logic.test.ts +164 -0
- package/src/native/__tests__/organise-screens.test.tsx +424 -0
- package/src/native/__tests__/photo-viewer.test.tsx +226 -0
- package/src/native/__tests__/push-payload.test.ts +66 -0
- package/src/native/__tests__/push.test.ts +209 -0
- package/src/native/__tests__/recorder-machine.test.ts +96 -0
- package/src/native/__tests__/rows-actions.test.ts +76 -0
- package/src/native/__tests__/thread-layout.test.ts +41 -0
- package/src/native/__tests__/thread-screen.test.tsx +116 -0
- package/src/native/__tests__/tus.test.ts +154 -0
- package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
- package/src/native/__tests__/video-media.test.tsx +498 -0
- package/src/native/__tests__/video-recorder-state.test.ts +87 -0
- package/src/native/__tests__/video-upload.test.ts +227 -0
- package/src/native/access-error.tsx +31 -0
- package/src/native/actions.ts +42 -0
- package/src/native/attachment-cards.tsx +132 -0
- package/src/native/auto-retry.ts +69 -0
- package/src/native/availability.tsx +376 -0
- package/src/native/broadcast-sheet.tsx +193 -0
- package/src/native/composer-state.ts +302 -0
- package/src/native/composer.tsx +453 -0
- package/src/native/context.tsx +70 -0
- package/src/native/copy.ts +296 -0
- package/src/native/emoji-data.ts +21 -0
- package/src/native/emoji-picker.tsx +229 -0
- package/src/native/emoji.ts +118 -0
- package/src/native/icon-button.tsx +40 -0
- package/src/native/inbox-screen.tsx +122 -0
- package/src/native/index.ts +184 -0
- package/src/native/link-preview.tsx +104 -0
- package/src/native/media-colors.ts +15 -0
- package/src/native/media.tsx +187 -0
- package/src/native/message-actions-sheet.tsx +126 -0
- package/src/native/message-bubble.tsx +366 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +277 -0
- package/src/native/organise-copy.ts +195 -0
- package/src/native/photo-viewer.tsx +531 -0
- package/src/native/push.ts +375 -0
- package/src/native/recorder-machine.ts +176 -0
- package/src/native/rows.ts +81 -0
- package/src/native/schedule-sheet.tsx +328 -0
- package/src/native/search-screen.tsx +189 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +647 -0
- package/src/native/topics.tsx +234 -0
- package/src/native/tus.ts +282 -0
- package/src/native/use-composer.ts +71 -0
- package/src/native/use-jump.ts +91 -0
- package/src/native/use-voice-recorder.ts +234 -0
- package/src/native/video-player.tsx +410 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +274 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +301 -0
- package/src/react/index.tsx +894 -0
- package/src/react/previews.ts +51 -0
- package/src/server/assistant.ts +235 -0
- package/src/server/crypto.ts +86 -0
- package/src/server/expo.ts +119 -0
- package/src/server/fake-video.ts +171 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +58 -0
- package/src/server/moderation.ts +155 -0
- package/src/server/mux.ts +290 -0
- package/src/server/notifications.ts +130 -0
- package/src/server/runner.ts +468 -0
- package/src/server/transcribers.ts +111 -0
- package/src/server/types.ts +171 -0
- package/src/server/unfurl.ts +480 -0
- package/src/server/upload.ts +158 -0
- package/src/web/attachments.tsx +379 -0
- package/src/web/availability.tsx +299 -0
- package/src/web/broadcast.tsx +166 -0
- package/src/web/bubble.tsx +585 -0
- package/src/web/cards.tsx +136 -0
- package/src/web/composer.tsx +571 -0
- package/src/web/emoji-picker.tsx +84 -0
- package/src/web/error-boundary.tsx +33 -0
- package/src/web/format.ts +55 -0
- package/src/web/inbox.tsx +196 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +714 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +204 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +830 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +516 -0
- package/src/web/voice-note.tsx +197 -0
- package/dist/chunk-27UTR5FW.js +0 -322
- package/dist/chunk-27UTR5FW.js.map +0 -1
- package/dist/chunk-JQXZUEEL.js +0 -525
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- package/dist/chunk-SAY24E6V.js +0 -309
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* Small pure helpers for migration 0002's features, shared by every platform: the links a
|
|
4
|
+
* message's text carries (the same rule the database uses to queue previews), https checks
|
|
5
|
+
* for preview images, search snippets split at their « » marks, scheduling quick picks, and
|
|
6
|
+
* topic keys.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A query that looks like a credential (a reset token, a signed URL): never previewed. Mirrors genesis_chat.message_links. */
|
|
10
|
+
const CREDENTIAL = /[?&#;](access_token|id_token|refresh_token|token|auth|code|key|api_key|apikey|sig|signature|x-amz-signature|x-amz-credential|x-goog-signature|x-goog-credential|otp|secret|password|session|sessionid)=/i;
|
|
11
|
+
|
|
12
|
+
/** The https links in a message's text that get a preview, at most three, as genesis_chat.message_links finds them. */
|
|
13
|
+
export function extractLinks(body: string, max = 3): string[] {
|
|
14
|
+
const out: string[] = [];
|
|
15
|
+
for (const m of body.matchAll(/(https:\/\/[^\s<>()"']{4,})/g)) {
|
|
16
|
+
const url = m[1]!;
|
|
17
|
+
if (url.length <= 2000 && !CREDENTIAL.test(url) && !out.includes(url)) out.push(url);
|
|
18
|
+
if (out.length >= max) break;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** An absolute https URL with a host: the only kind a preview image may load from. */
|
|
24
|
+
export function isHttpsUrl(url: string | null | undefined): url is string {
|
|
25
|
+
if (!url) return false;
|
|
26
|
+
try {
|
|
27
|
+
const u = new URL(url);
|
|
28
|
+
return u.protocol === "https:" && Boolean(u.hostname);
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SnippetPart {
|
|
35
|
+
text: string;
|
|
36
|
+
match: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** "a «word» here" → [{a }, {word, match}, { here}]. Unbalanced marks are kept as text. */
|
|
40
|
+
export function snippetParts(snippet: string): SnippetPart[] {
|
|
41
|
+
const parts: SnippetPart[] = [];
|
|
42
|
+
let rest = snippet;
|
|
43
|
+
for (;;) {
|
|
44
|
+
const open = rest.indexOf("«");
|
|
45
|
+
const close = open >= 0 ? rest.indexOf("»", open + 1) : -1;
|
|
46
|
+
if (open < 0 || close < 0) break;
|
|
47
|
+
if (open > 0) parts.push({ text: rest.slice(0, open), match: false });
|
|
48
|
+
parts.push({ text: rest.slice(open + 1, close), match: true });
|
|
49
|
+
rest = rest.slice(close + 1);
|
|
50
|
+
}
|
|
51
|
+
if (rest) parts.push({ text: rest, match: false });
|
|
52
|
+
return parts;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface QuickPick {
|
|
56
|
+
label: string;
|
|
57
|
+
at: Date;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** At 09:00 local on the day `days` after `from`. */
|
|
61
|
+
function nineAm(from: Date, days: number): Date {
|
|
62
|
+
const d = new Date(from.getFullYear(), from.getMonth(), from.getDate() + days, 9, 0, 0, 0);
|
|
63
|
+
return d;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** "Tomorrow, 9:00 AM" and "Monday, 9:00 AM" (the next Monday after tomorrow; one pick when they're the same day). */
|
|
67
|
+
export function scheduleQuickPicks(now: Date = new Date(), locale?: string): QuickPick[] {
|
|
68
|
+
const time = (d: Date) => d.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" });
|
|
69
|
+
const tomorrow = nineAm(now, 1);
|
|
70
|
+
const picks: QuickPick[] = [{ label: `Tomorrow, ${time(tomorrow)}`, at: tomorrow }];
|
|
71
|
+
const toMonday = ((8 - now.getDay()) % 7) || 7;
|
|
72
|
+
const monday = nineAm(now, toMonday);
|
|
73
|
+
if (monday.getTime() !== tomorrow.getTime()) {
|
|
74
|
+
picks.push({ label: `${monday.toLocaleDateString(locale, { weekday: "long" })}, ${time(monday)}`, at: monday });
|
|
75
|
+
}
|
|
76
|
+
return picks;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** When a scheduled message goes: "Tomorrow, 9:00 AM", "Mon 12 Oct, 9:00 AM". */
|
|
80
|
+
export function scheduleLabel(iso: string | Date, now: Date = new Date(), locale?: string): string {
|
|
81
|
+
const d = typeof iso === "string" ? new Date(iso) : iso;
|
|
82
|
+
const time = d.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" });
|
|
83
|
+
const start = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
84
|
+
const days = Math.round((start(d) - start(now)) / 86_400_000);
|
|
85
|
+
if (days === 0) return `Today, ${time}`;
|
|
86
|
+
if (days === 1) return `Tomorrow, ${time}`;
|
|
87
|
+
return `${d.toLocaleDateString(locale, { weekday: "short", day: "numeric", month: "short" })}, ${time}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The send time the database accepts: at least a minute and at most a year ahead. */
|
|
91
|
+
export function isSchedulable(at: Date, now: Date = new Date()): boolean {
|
|
92
|
+
const t = at.getTime();
|
|
93
|
+
return Number.isFinite(t) && t >= now.getTime() + 60_000 && t <= now.getTime() + 365 * 86_400_000;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A topic's key from its title: "Week 1: Habits" → "week-1-habits" (at most 120 characters). */
|
|
97
|
+
export function topicKey(title: string): string {
|
|
98
|
+
const key = title
|
|
99
|
+
.normalize("NFKD")
|
|
100
|
+
.replace(/[̀-ͯ]/g, "")
|
|
101
|
+
.toLowerCase()
|
|
102
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
103
|
+
.replace(/^-+|-+$/g, "")
|
|
104
|
+
.slice(0, 120);
|
|
105
|
+
return key || title.trim().slice(0, 120);
|
|
106
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* Media rules shared by every platform: which files may be sent (the same list
|
|
4
|
+
* migration 0001 checks), what kind an upload becomes, the default limits, and
|
|
5
|
+
* the waveform a voice note carries so its bubble draws before the audio loads.
|
|
6
|
+
*/
|
|
7
|
+
import type { AttachmentKind } from "./types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A random UUID (v4): `crypto.randomUUID` where the platform has it (some React Native
|
|
11
|
+
* runtimes don't), else `crypto.getRandomValues`, else Math.random as a last resort. Ids
|
|
12
|
+
* only need to be unique per sender: the server checks (sender, client_id).
|
|
13
|
+
*/
|
|
14
|
+
export function randomId(): string {
|
|
15
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string; getRandomValues?: (a: Uint8Array) => Uint8Array } }).crypto;
|
|
16
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
17
|
+
const b = new Uint8Array(16);
|
|
18
|
+
if (c?.getRandomValues) c.getRandomValues(b);
|
|
19
|
+
else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256);
|
|
20
|
+
b[6] = (b[6]! & 0x0f) | 0x40;
|
|
21
|
+
b[8] = (b[8]! & 0x3f) | 0x80;
|
|
22
|
+
const h = [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
|
23
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Mirrors genesis_chat.allowed_extension. Never SVG or HTML, which a browser runs as a page. */
|
|
27
|
+
export const ALLOWED_EXTENSIONS = [
|
|
28
|
+
"png", "jpg", "jpeg", "webp", "gif", "heic", "heif",
|
|
29
|
+
"m4a", "aac", "mp3", "ogg", "oga", "opus", "weba", "webm",
|
|
30
|
+
"mp4", "mov", "pdf", "txt", "csv", "docx", "xlsx", "pptx",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
const MIME_EXTENSIONS: Record<string, string> = {
|
|
34
|
+
"image/png": "png",
|
|
35
|
+
"image/jpeg": "jpg",
|
|
36
|
+
"image/webp": "webp",
|
|
37
|
+
"image/gif": "gif",
|
|
38
|
+
"image/heic": "heic",
|
|
39
|
+
"image/heif": "heif",
|
|
40
|
+
"audio/mp4": "m4a",
|
|
41
|
+
"audio/x-m4a": "m4a",
|
|
42
|
+
"audio/aac": "aac",
|
|
43
|
+
"audio/mpeg": "mp3",
|
|
44
|
+
"audio/ogg": "ogg",
|
|
45
|
+
"audio/webm": "webm",
|
|
46
|
+
"video/mp4": "mp4",
|
|
47
|
+
"video/quicktime": "mov",
|
|
48
|
+
"video/webm": "webm",
|
|
49
|
+
"application/pdf": "pdf",
|
|
50
|
+
"text/plain": "txt",
|
|
51
|
+
"text/csv": "csv",
|
|
52
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
|
53
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
|
54
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** The extension a file is stored under: from its content type, else its name. */
|
|
58
|
+
export function fileExtension(name: string, contentType?: string): string {
|
|
59
|
+
const base = (contentType ?? "").split(";")[0]!.trim().toLowerCase();
|
|
60
|
+
const fromType = MIME_EXTENSIONS[base];
|
|
61
|
+
if (fromType) return fromType;
|
|
62
|
+
const dot = name.lastIndexOf(".");
|
|
63
|
+
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isAllowedExtension(ext: string): boolean {
|
|
67
|
+
return (ALLOWED_EXTENSIONS as readonly string[]).includes(ext.toLowerCase());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What an upload becomes in a message. */
|
|
71
|
+
export function attachmentKindFor(contentType: string, opts: { recordedVoice?: boolean } = {}): AttachmentKind {
|
|
72
|
+
const t = contentType.toLowerCase();
|
|
73
|
+
if (opts.recordedVoice || t.startsWith("audio/")) return "voice";
|
|
74
|
+
if (t.startsWith("image/")) return "image";
|
|
75
|
+
if (t.startsWith("video/")) return "video";
|
|
76
|
+
return "file";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Default limits (decision 5): an app may lower them, not raise them past the bucket's 100 MB. */
|
|
80
|
+
export const CHAT_LIMITS = {
|
|
81
|
+
/** Voice notes and videos, for members. */
|
|
82
|
+
recordingMs: 5 * 60_000,
|
|
83
|
+
/** For owners, admins and observers (coaches, staff). */
|
|
84
|
+
staffRecordingMs: 10 * 60_000,
|
|
85
|
+
fileBytes: 50 * 1024 * 1024,
|
|
86
|
+
videoBytes: 100 * 1024 * 1024,
|
|
87
|
+
attachmentsPerMessage: 20,
|
|
88
|
+
bodyChars: 10_000,
|
|
89
|
+
} as const;
|
|
90
|
+
|
|
91
|
+
/** The longest recording this role may make. */
|
|
92
|
+
export function recordingLimitMs(role: string | null | undefined): number {
|
|
93
|
+
return role === "owner" || role === "admin" || role === "observer" ? CHAT_LIMITS.staffRecordingMs : CHAT_LIMITS.recordingMs;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Peaks for a waveform: `count` values 0–100 from raw samples (-1…1), the loudest
|
|
98
|
+
* in each bucket, scaled so the loudest bucket is 100. Quiet recordings still show
|
|
99
|
+
* a shape; silence shows a flat line.
|
|
100
|
+
*/
|
|
101
|
+
export function waveformPeaks(samples: ArrayLike<number>, count = 48): number[] {
|
|
102
|
+
const n = Math.max(1, Math.min(128, Math.floor(count)));
|
|
103
|
+
if (samples.length === 0) return new Array(n).fill(0);
|
|
104
|
+
const size = samples.length / n;
|
|
105
|
+
const raw: number[] = [];
|
|
106
|
+
for (let i = 0; i < n; i++) {
|
|
107
|
+
let peak = 0;
|
|
108
|
+
const end = Math.min(samples.length, Math.floor((i + 1) * size));
|
|
109
|
+
for (let j = Math.floor(i * size); j < end; j++) {
|
|
110
|
+
const v = Math.abs(samples[j]!);
|
|
111
|
+
if (v > peak) peak = v;
|
|
112
|
+
}
|
|
113
|
+
raw.push(peak);
|
|
114
|
+
}
|
|
115
|
+
const max = Math.max(...raw);
|
|
116
|
+
if (max < 0.01) return raw.map(() => 0);
|
|
117
|
+
return raw.map((v) => Math.round((v / max) * 100));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Peaks from a level meter read while recording (expo-audio's `metering`, in dB,
|
|
122
|
+
* about -160…0): resampled to `count` values 0–100.
|
|
123
|
+
*/
|
|
124
|
+
export function waveformFromMetering(levelsDb: number[], count = 48): number[] {
|
|
125
|
+
const linear = levelsDb.map((db) => (Number.isFinite(db) ? Math.pow(10, Math.max(-60, Math.min(0, db)) / 20) : 0));
|
|
126
|
+
return waveformPeaks(linear, count);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 0:42, 1:05:09. */
|
|
130
|
+
export function formatDuration(ms: number | null | undefined): string {
|
|
131
|
+
const total = Math.max(0, Math.round((ms ?? 0) / 1000));
|
|
132
|
+
const h = Math.floor(total / 3600);
|
|
133
|
+
const m = Math.floor((total % 3600) / 60);
|
|
134
|
+
const s = total % 60;
|
|
135
|
+
return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Playback speeds offered for voice and video (free for everyone, decision 5). */
|
|
139
|
+
export const PLAYBACK_RATES = [1, 1.5, 2] as const;
|
|
140
|
+
|
|
141
|
+
export function nextPlaybackRate(rate: number): number {
|
|
142
|
+
const i = PLAYBACK_RATES.indexOf(rate as (typeof PLAYBACK_RATES)[number]);
|
|
143
|
+
return PLAYBACK_RATES[(i + 1) % PLAYBACK_RATES.length]!;
|
|
144
|
+
}
|