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:
asepharyana
2026-08-30 12:59:46 +07:00
parent e3016a858a
commit 75ff9274b3
4 changed files with 421 additions and 35 deletions
@@ -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"
+37 -21
View File
@@ -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;
+126
View File
@@ -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);
}