diff --git a/.hermes/plans/2026-08-30_recordings-filter-export-wav-spec.md b/.hermes/plans/2026-08-30_recordings-filter-export-wav-spec.md new file mode 100644 index 00000000..5e9825ce --- /dev/null +++ b/.hermes/plans/2026-08-30_recordings-filter-export-wav-spec.md @@ -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` — 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 + . +- **`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. diff --git a/services/frontend/src/app/(dashboard)/recordings/view.tsx b/services/frontend/src/app/(dashboard)/recordings/view.tsx index 533e6e77..bd75d85c 100644 --- a/services/frontend/src/app/(dashboard)/recordings/view.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/view.tsx @@ -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(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(null); + const [exportingIds, setExportingIds] = useState>(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(() => { + const map = new Map(); + 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 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 (
@@ -160,6 +257,47 @@ export function RecordingsView({
+ {/* Filter + Export toolbar */} +
+
+ +