feat(frontend): filter recordings per-speaker + export WAV for Audacity
- recordings page: speaker filter dropdown (server-side userId filter via existing recordings.list proc) + clear-filter + pagination reset on change - WAV export per card: decode download_url via Web Audio, re-encode 16-bit PCM WAV (Audacity-ready) client-side, no server/ffmpeg needed - EXPORT WAV (N) header button: concatenate visible (filtered) recordings into a single mono WAV for mixdown/analysis - hooks: per-filter SWR cache key (recordings/<userId|all>); delete & live WS-sync invalidate every filter cache via key matcher - new lib/audio/wav.ts: decodeAudio / audioBufferToWav / concatAudioBuffers / downloadBlob
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# Spec: Recordings — filter per user + export WAV (Audacity)
|
||||
|
||||
## Konteks / Gejala
|
||||
Halaman `services/frontend/src/app/(dashboard)/recordings` menampilkan semua
|
||||
rekaman voice (deck). User ingin:
|
||||
1. **Filter per orang** (tampil rekaman satu user saja).
|
||||
2. **Export ke format untuk Audacity** (buka & edit rekaman di Audacity).
|
||||
|
||||
## Fakta saat ini (verified)
|
||||
- Backend `recordings.list` (services/backend/src/orpc/router.ts:291) SUDAH
|
||||
menerima `userId`/`channelId` filter → `RecordingsService.getRecent`.
|
||||
- Frontend `recordingsApi.list(limit, channelId, userId, cursor)` (lib/api/recordings.ts)
|
||||
sudah meneruskan `userId`. `useLoadMoreRecordings` juga sudah bawa userId.
|
||||
- Tapi UI `RecordingsView` (app/(dashboard)/recordings/view.tsx) TIDAK punya
|
||||
filter UI, dan `useRecordingsPage` dipanggil tanpa userId → semua tampil.
|
||||
- Setiap rekaman punya `download_url` (MP3 di TeleUploader), `user_id`, `username`.
|
||||
- Audacity membuka MP3/OGG tapi editing paling bersih dari WAV (uncompressed)
|
||||
/ FLAC (lossless). Backend TIDAK punya ffmpeg & Nix flake backend tak include
|
||||
ffmpeg → transcode server-side bukan pilihan. Browser punya codec MP3 → export
|
||||
WAV via Web Audio API (client-side) adalah solusi self-contained terbaik.
|
||||
|
||||
## Keputusan desain
|
||||
1. **Filter per user**: UI dropdown (Semua User + per user) di header halaman.
|
||||
Memilih user → re-fetch `recordingsApi.list(50, undefined, userId)` (server
|
||||
filter, benar untuk dataset besar + pagination). Dropdown dibangun dari
|
||||
distinct `user_id`/`username` pada items yang sedang tampil.
|
||||
2. **Export WAV (Audacity)**: client-side via Web Audio API.
|
||||
- Per kartu: tombol "WAV" → decode `download_url` → WAV 16-bit PCM → download.
|
||||
- Header: tombol "EXPORT WAV (N)" → gabung (concat) semua rekaman yang
|
||||
sedang tampil (ter-filter) jadi 1 file WAV → download. Ideal untuk analisis
|
||||
/ mixdown per orang.
|
||||
- Implementasi di `lib/audio/wav.ts` (decode + encode + concat), tanpa dep baru.
|
||||
|
||||
## Perubahan
|
||||
|
||||
### Frontend
|
||||
- **`src/lib/audio/wav.ts`** (baru):
|
||||
- `decodeAudio(url: string): Promise<AudioBuffer>` — fetch arrayBuffer →
|
||||
`new AudioContext().decodeAudioData`.
|
||||
- `audioBufferToWav(buf: AudioBuffer, sampleRate=48000): Blob` — PCM 16-bit
|
||||
interleaved, mono→stereo handling, RIFF/WAVE writer. Audacity-importable.
|
||||
- `concatBuffers(buffers: AudioBuffer[]): AudioBuffer` — gabung di channel 0
|
||||
(mono) dengan sample-rate max; untuk export gabungan.
|
||||
- `downloadWav(blob: Blob, filename: string): void` — obj URL + <a download>.
|
||||
- **`src/app/(dashboard)/recordings/view.tsx`**:
|
||||
- Toolbar filter: dropdown user (built from distinct items) + tombol reset.
|
||||
- State `filterUserId`; saat berubah → `recordingsApi.list(50, undefined, id)`
|
||||
→ set ke SWR (key includes filter), reset pagination.
|
||||
- Tombol "WAV" per kartu (disabled jika `!r.download_url`).
|
||||
- Tombol "EXPORT WAV (N)" di header (disabled jika 0 item punya download_url);
|
||||
concat semua items ter-filter yang punya download_url.
|
||||
- Status loading saat export (spinner/disable).
|
||||
- **`src/hooks/use-recordings.ts`**: `useRecordingsPage` menerima `userId?` dan
|
||||
memasukkan ke key + call, supaya filter re-fetch bersih (per-user cache key).
|
||||
`useRecordings`/`useLoadMoreRecordings` propagate `userId`.
|
||||
- **`src/lib/types/recording.ts`**: tidak berubah (userId dari items).
|
||||
|
||||
### Backend / gateway
|
||||
- Tidak ada perubahan. Filter & export sepenuhnya frontend.
|
||||
|
||||
## File yang disentuh (frontend only)
|
||||
- `src/lib/audio/wav.ts` (baru)
|
||||
- `src/app/(dashboard)/recordings/view.tsx`
|
||||
- `src/hooks/use-recordings.ts`
|
||||
|
||||
## Verification
|
||||
1. `cd services/frontend && pnpm typecheck` (tsc --noEmit) — 0 error.
|
||||
2. `pnpm lint` (biome check src/) — exit 0.
|
||||
3. `pnpm build` (next build) — hijau.
|
||||
4. Manual (user): buka /recordings; pilih user di dropdown → hanya rekaman user
|
||||
itu; klik WAV di kartu → file .wav ter-download & terbuka di Audacity; klik
|
||||
EXPORT WAV (filtered) → satu .wav gabungan.
|
||||
5. Push → CI `Build & Deploy (Nix)` (frontend job) hijau → deploy landing.
|
||||
|
||||
## Risiko / Trade-off
|
||||
- Web Audio decode MP3 di client: butuh CORS pada download_url (TeleUploader
|
||||
asepharyana.my.id — sudah same-serve/proxied, CORS ikut origin). Jika 403/CORS
|
||||
gagal, error toaster + fallback manual (RAW MP3 tetap ada).
|
||||
- concat gabungan = mono 48k; Audacity bisa edit per-channel nanti. Acceptable.
|
||||
- Filter client (dropdown dari items yang dimuat) hanya menawarkan user yang
|
||||
sudah tampil; dataset besar bisa pakai search nanti. Server filter benar untuk
|
||||
yang dipilih.
|
||||
@@ -1,13 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Hash, Headphones, Loader2, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Download,
|
||||
FileAudio,
|
||||
Files,
|
||||
Hash,
|
||||
Headphones,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
GlassCard,
|
||||
GlassPanel,
|
||||
Select,
|
||||
type SelectOption,
|
||||
Skeleton,
|
||||
toast,
|
||||
} from "@/components/primitives";
|
||||
@@ -23,16 +35,25 @@ import {
|
||||
useRecordingsWsSync,
|
||||
} from "@/hooks";
|
||||
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||
import {
|
||||
audioBufferToWav,
|
||||
concatAudioBuffers,
|
||||
decodeAudio,
|
||||
downloadBlob,
|
||||
} from "@/lib/audio/wav";
|
||||
import { formatBytes, formatRelativeTime } from "@/lib/format";
|
||||
import type { PaginatedRecordings, VoiceRecording } from "@/lib/types";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
const ALL_USERS = "__all__";
|
||||
|
||||
export function RecordingsView({
|
||||
initialPage,
|
||||
}: {
|
||||
initialPage?: PaginatedRecordings;
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const [filterUserId, setFilterUserId] = useState<string | null>(null);
|
||||
const {
|
||||
data: items,
|
||||
isLoading,
|
||||
@@ -40,12 +61,14 @@ export function RecordingsView({
|
||||
nextCursor,
|
||||
hasMore,
|
||||
mutate,
|
||||
} = useRecordings(initialPage);
|
||||
const loadMore = useLoadMoreRecordings();
|
||||
} = useRecordings(initialPage, filterUserId ?? undefined);
|
||||
const loadMore = useLoadMoreRecordings(filterUserId ?? undefined);
|
||||
const del = useDeleteRecording();
|
||||
useRecordingsWsSync(ws);
|
||||
const ambient = useAmbient();
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
const [exportingIds, setExportingIds] = useState<Set<string>>(new Set());
|
||||
const [exportingAll, setExportingAll] = useState(false);
|
||||
|
||||
// Maximum older pages to prevent infinite runaway memory usage
|
||||
const MAX_OLDER_PAGES = 10;
|
||||
@@ -64,6 +87,19 @@ export function RecordingsView({
|
||||
ambient.set("signal", 0.3, "recordings");
|
||||
}, [ambient]);
|
||||
|
||||
// Distinct speakers visible in the current (filtered) list, for the dropdown.
|
||||
const userOptions = useMemo<SelectOption[]>(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const r of items ?? []) {
|
||||
if (r.user_id && !map.has(r.user_id)) map.set(r.user_id, r.username);
|
||||
}
|
||||
const opts: SelectOption[] = [
|
||||
{ value: ALL_USERS, label: "All speakers" },
|
||||
...[...map.entries()].map(([value, label]) => ({ value, label })),
|
||||
];
|
||||
return opts;
|
||||
}, [items]);
|
||||
|
||||
const loadOlder = useCallback(async () => {
|
||||
if (
|
||||
!hasMore ||
|
||||
@@ -111,6 +147,66 @@ export function RecordingsView({
|
||||
}
|
||||
};
|
||||
|
||||
const onExportOne = async (r: VoiceRecording) => {
|
||||
if (!r.download_url) return;
|
||||
if (exportingIds.has(r.id)) return;
|
||||
setExportingIds((prev) => new Set(prev).add(r.id));
|
||||
try {
|
||||
const buf = await decodeAudio(r.download_url);
|
||||
const wav = audioBufferToWav(buf);
|
||||
const safeName = (r.username ?? "speaker").replace(/[^\w.-]+/g, "_");
|
||||
downloadBlob(wav, `gmw-rec-${safeName}-${r.id.slice(0, 8)}.wav`);
|
||||
toast({ title: "WAV exported (Audacity-ready)", tone: "signal" });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Export failed",
|
||||
description: String(e),
|
||||
tone: "vermilion",
|
||||
});
|
||||
} finally {
|
||||
setExportingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(r.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onExportAll = async () => {
|
||||
if (exportingAll) return;
|
||||
const target = (items ?? []).filter(
|
||||
(r): r is VoiceRecording & { download_url: string } =>
|
||||
Boolean(r.download_url),
|
||||
);
|
||||
if (target.length === 0) return;
|
||||
setExportingAll(true);
|
||||
try {
|
||||
const buffers = [];
|
||||
for (const r of target) {
|
||||
buffers.push(await decodeAudio(r.download_url));
|
||||
}
|
||||
const merged = concatAudioBuffers(buffers);
|
||||
if (!merged) throw new Error("No audio decoded");
|
||||
const wav = audioBufferToWav(merged);
|
||||
const who = filterUserId
|
||||
? (target[0]?.username ?? "speaker").replace(/[^\w.-]+/g, "_")
|
||||
: "all";
|
||||
downloadBlob(wav, `gmw-rec-${who}-${target.length}clips.wav`);
|
||||
toast({
|
||||
title: `Exported ${target.length} clips as one WAV`,
|
||||
tone: "signal",
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Export failed",
|
||||
description: String(e),
|
||||
tone: "vermilion",
|
||||
});
|
||||
} finally {
|
||||
setExportingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error && !items)
|
||||
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
||||
if (!items && isLoading)
|
||||
@@ -138,6 +234,7 @@ export function RecordingsView({
|
||||
);
|
||||
|
||||
const totalRecordings = (items ?? []).length;
|
||||
const exportableCount = (items ?? []).filter((r) => r.download_url).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -160,6 +257,47 @@ export function RecordingsView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter + Export toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="size-3.5 text-ink-faint" />
|
||||
<Select
|
||||
value={filterUserId ?? ALL_USERS}
|
||||
onChange={(v) => {
|
||||
setFilterUserId(v === ALL_USERS ? null : v);
|
||||
setLoadedPages(0);
|
||||
}}
|
||||
options={userOptions}
|
||||
placeholder="Filter by speaker"
|
||||
size="sm"
|
||||
className="w-48"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onExportAll}
|
||||
disabled={exportingAll || exportableCount === 0}
|
||||
title="Concatenate the visible (filtered) recordings into one WAV for Audacity"
|
||||
>
|
||||
{exportingAll ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Files className="size-3.5" />
|
||||
)}
|
||||
{exportingAll ? "RENDERING..." : `EXPORT WAV (${exportableCount})`}
|
||||
</Button>
|
||||
{filterUserId && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => setFilterUserId(null)}
|
||||
>
|
||||
CLEAR FILTER
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="acoustic buffer"
|
||||
@@ -173,8 +311,16 @@ export function RecordingsView({
|
||||
{totalRecordings === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Headphones className="size-7" />}
|
||||
title="Tape deck is empty"
|
||||
description="Voice transmissions captured in connected channels will be archived here."
|
||||
title={
|
||||
filterUserId
|
||||
? "No recordings for this speaker"
|
||||
: "Tape deck is empty"
|
||||
}
|
||||
description={
|
||||
filterUserId
|
||||
? "This speaker has no captured transmissions (or the filter is stale)."
|
||||
: "Voice transmissions captured in connected channels will be archived here."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
@@ -188,6 +334,7 @@ export function RecordingsView({
|
||||
{(items ?? []).map((r) => {
|
||||
const up = uploadStatus(r);
|
||||
const isPlaying = playingId === r.id;
|
||||
const isExporting = exportingIds.has(r.id);
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
@@ -264,14 +411,29 @@ export function RecordingsView({
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{r.download_url && (
|
||||
<a
|
||||
href={r.download_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-hairline bg-surface-2 px-2 py-1 font-mono text-[10px] text-ink-soft transition-colors hover:bg-surface hover:text-ink"
|
||||
>
|
||||
<Download className="size-3 text-signal" /> RAW
|
||||
</a>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExportOne(r)}
|
||||
disabled={isExporting}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-signal/40 bg-signal/10 px-2 py-1 font-mono text-[10px] text-signal transition-colors hover:bg-signal/20"
|
||||
>
|
||||
{isExporting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<FileAudio className="size-3" />
|
||||
)}
|
||||
{isExporting ? "WAV..." : "WAV"}
|
||||
</button>
|
||||
<a
|
||||
href={r.download_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-hairline bg-surface-2 px-2 py-1 font-mono text-[10px] text-ink-soft transition-colors hover:bg-surface hover:text-ink"
|
||||
>
|
||||
<Download className="size-3 text-signal" /> RAW
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -5,18 +5,40 @@ import { recordingsApi } from "@/lib/api";
|
||||
import type { PaginatedRecordings, VoiceRecording } from "@/lib/types";
|
||||
import type { WsHook } from "@/lib/ws-hook";
|
||||
|
||||
const RECORDINGS_KEY = ["recordings"] as const;
|
||||
const RECORDINGS_PREFIX = "recordings";
|
||||
|
||||
export function useRecordingsPage(initialPage?: PaginatedRecordings) {
|
||||
return useSWR<PaginatedRecordings>(
|
||||
RECORDINGS_KEY,
|
||||
() => recordingsApi.list(50),
|
||||
{ fallbackData: initialPage },
|
||||
function recordingsKey(userId?: string): [string, string] {
|
||||
return [RECORDINGS_PREFIX, userId ?? "all"];
|
||||
}
|
||||
|
||||
/** Revalidate/mutate every recording cache (all filters + global list). */
|
||||
function isRecordingsKey(key: unknown): boolean {
|
||||
return (
|
||||
Array.isArray(key) &&
|
||||
key.length === 2 &&
|
||||
key[0] === RECORDINGS_PREFIX &&
|
||||
typeof key[1] === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export function useRecordings(initialPage?: PaginatedRecordings) {
|
||||
const page = useRecordingsPage(initialPage);
|
||||
export function useRecordingsPage(
|
||||
initialPage?: PaginatedRecordings,
|
||||
userId?: string,
|
||||
) {
|
||||
return useSWR<PaginatedRecordings>(
|
||||
recordingsKey(userId),
|
||||
() => recordingsApi.list(50, undefined, userId),
|
||||
// fallbackData only applies to the unfiltered "all" view; a filtered cache
|
||||
// must not be pre-seeded with unfiltered rows (would flash wrong data).
|
||||
{ fallbackData: userId ? undefined : initialPage },
|
||||
);
|
||||
}
|
||||
|
||||
export function useRecordings(
|
||||
initialPage?: PaginatedRecordings,
|
||||
userId?: string,
|
||||
) {
|
||||
const page = useRecordingsPage(initialPage, userId);
|
||||
return {
|
||||
...page,
|
||||
data: page.data?.items,
|
||||
@@ -26,21 +48,13 @@ export function useRecordings(initialPage?: PaginatedRecordings) {
|
||||
};
|
||||
}
|
||||
|
||||
export function useLoadMoreRecordings() {
|
||||
export function useLoadMoreRecordings(userId?: string) {
|
||||
const { mutate } = useSWRConfig();
|
||||
return useAction(
|
||||
async ({
|
||||
channelId,
|
||||
userId,
|
||||
cursor,
|
||||
}: {
|
||||
channelId?: string;
|
||||
userId?: string;
|
||||
cursor: string;
|
||||
}) => {
|
||||
async ({ channelId, cursor }: { channelId?: string; cursor: string }) => {
|
||||
const result = await recordingsApi.list(50, channelId, userId, cursor);
|
||||
await mutate(
|
||||
RECORDINGS_KEY,
|
||||
recordingsKey(userId),
|
||||
(old: PaginatedRecordings | undefined): PaginatedRecordings => {
|
||||
if (!old) return result;
|
||||
const existingIds = new Set(old.items.map((r) => r.id));
|
||||
@@ -62,8 +76,9 @@ export function useDeleteRecording() {
|
||||
const { mutate } = useSWRConfig();
|
||||
return useAction((id: string) => recordingsApi.delete(id), {
|
||||
onSuccess: (_, id) => {
|
||||
// A deleted recording disappears from every filter view.
|
||||
void mutate(
|
||||
RECORDINGS_KEY,
|
||||
(key: unknown) => isRecordingsKey(key),
|
||||
(
|
||||
old: PaginatedRecordings | undefined,
|
||||
): PaginatedRecordings | undefined => {
|
||||
@@ -84,8 +99,9 @@ export function useRecordingsWsSync(ws: WsHook) {
|
||||
useEffect(() => {
|
||||
const unsub = ws.on("voice_recording_uploaded", (data) => {
|
||||
const rec = data as VoiceRecording;
|
||||
// A fresh recording should appear in every filter view it belongs to.
|
||||
void mutate(
|
||||
RECORDINGS_KEY,
|
||||
(key: unknown) => isRecordingsKey(key),
|
||||
(old: PaginatedRecordings | undefined): PaginatedRecordings => {
|
||||
if (!old) return { items: [rec], nextCursor: null, hasMore: false };
|
||||
if (old.items.some((r) => r.id === rec.id)) return old;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Client-side audio export helpers (no server/ffmpeg needed).
|
||||
*
|
||||
* Fetches a remote MP3/OGG, decodes it with the browser's codec stack
|
||||
* (Web Audio API), and re-encodes as 16-bit PCM WAV — the format Audacity
|
||||
* opens cleanest for editing. Also supports concatenating several decoded
|
||||
* clips into a single WAV (e.g. every recording from one filtered user).
|
||||
*/
|
||||
|
||||
/** Get a shared AudioContext (created lazily; kept suspended for decode). */
|
||||
function getCtx(): AudioContext {
|
||||
const Ctor =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
if (!Ctor) throw new Error("Web Audio API is not available in this browser");
|
||||
return new Ctor();
|
||||
}
|
||||
|
||||
/** Fetch a remote audio URL and decode it to an AudioBuffer. */
|
||||
export async function decodeAudio(url: string): Promise<AudioBuffer> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to fetch audio (${res.status})`);
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const ctx = getCtx();
|
||||
try {
|
||||
return await ctx.decodeAudioData(arrayBuffer);
|
||||
} finally {
|
||||
// free the context resources; decodeAudioData is standalone
|
||||
void ctx.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an AudioBuffer to a 16-bit PCM WAV Blob (RIFF).
|
||||
* Multi-channel buffers are preserved (interleaved). Audacity imports this
|
||||
* as one track per channel.
|
||||
*/
|
||||
export function audioBufferToWav(buf: AudioBuffer): Blob {
|
||||
const numChannels = Math.max(1, buf.numberOfChannels);
|
||||
const sampleRate = buf.sampleRate;
|
||||
const frames = buf.length;
|
||||
const bytesPerSample = 2; // 16-bit
|
||||
const blockAlign = numChannels * bytesPerSample;
|
||||
const dataSize = frames * blockAlign;
|
||||
|
||||
const buffer = new ArrayBuffer(44 + dataSize);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
const writeString = (offset: number, str: string): void => {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
view.setUint8(offset + i, str.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
|
||||
// RIFF header
|
||||
writeString(0, "RIFF");
|
||||
view.setUint32(4, 36 + dataSize, true);
|
||||
writeString(8, "WAVE");
|
||||
writeString(12, "fmt ");
|
||||
view.setUint32(16, 16, true); // fmt chunk size
|
||||
view.setUint16(20, 1, true); // PCM
|
||||
view.setUint16(22, numChannels, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * blockAlign, true); // byte rate
|
||||
view.setUint16(32, blockAlign, true);
|
||||
view.setUint16(34, 16, true); // bits per sample
|
||||
writeString(36, "data");
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
// Interleave channel data into the sample block. Clamp to [-1, 1].
|
||||
let offset = 44;
|
||||
for (let frame = 0; frame < frames; frame++) {
|
||||
for (let ch = 0; ch < numChannels; ch++) {
|
||||
const data = buf.getChannelData(ch);
|
||||
const s = Math.max(-1, Math.min(1, data[frame]));
|
||||
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||
offset += 2;
|
||||
}
|
||||
}
|
||||
|
||||
return new Blob([buffer], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate several decoded clips into one mono AudioBuffer (channel 0).
|
||||
* Uses the highest sample rate among inputs. Returns null if `buffers` is empty.
|
||||
*/
|
||||
export function concatAudioBuffers(buffers: AudioBuffer[]): AudioBuffer | null {
|
||||
if (buffers.length === 0) return null;
|
||||
const rate = Math.max(...buffers.map((b) => b.sampleRate));
|
||||
const totalFrames = buffers.reduce((acc, b) => acc + b.length, 0);
|
||||
const ctx = getCtx();
|
||||
const out = ctx.createBuffer(1, totalFrames, rate);
|
||||
const target = out.getChannelData(0);
|
||||
|
||||
let write = 0;
|
||||
for (const b of buffers) {
|
||||
const src = b.getChannelData(0);
|
||||
// Resample if the source rate differs from the target rate.
|
||||
if (b.sampleRate === rate) {
|
||||
target.set(src, write);
|
||||
write += src.length;
|
||||
} else {
|
||||
const step = b.sampleRate / rate;
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
target[Math.floor(write + i * step)] = src[i];
|
||||
}
|
||||
write += Math.floor(src.length / step);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Trigger a browser download for a Blob. */
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// revoke on next tick so the download has a chance to start
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
Reference in New Issue
Block a user