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:
1 parent
e3016a858a
commit
75ff9274b3
4 files changed
+421
-35
No files matched your search
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { Download, Hash, Headphones, Loader2, Trash2 } from "lucide-react";
|
import {
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
|
Button,
|
||||||
GlassCard,
|
GlassCard,
|
||||||
GlassPanel,
|
GlassPanel,
|
||||||
|
Select,
|
||||||
|
type SelectOption,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
toast,
|
toast,
|
||||||
} from "@/components/primitives";
|
} from "@/components/primitives";
|
||||||
@@ -23,16 +35,25 @@ import {
|
|||||||
useRecordingsWsSync,
|
useRecordingsWsSync,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||||
|
import {
|
||||||
|
audioBufferToWav,
|
||||||
|
concatAudioBuffers,
|
||||||
|
decodeAudio,
|
||||||
|
downloadBlob,
|
||||||
|
} from "@/lib/audio/wav";
|
||||||
import { formatBytes, formatRelativeTime } from "@/lib/format";
|
import { formatBytes, formatRelativeTime } from "@/lib/format";
|
||||||
import type { PaginatedRecordings, VoiceRecording } from "@/lib/types";
|
import type { PaginatedRecordings, VoiceRecording } from "@/lib/types";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
|
const ALL_USERS = "__all__";
|
||||||
|
|
||||||
export function RecordingsView({
|
export function RecordingsView({
|
||||||
initialPage,
|
initialPage,
|
||||||
}: {
|
}: {
|
||||||
initialPage?: PaginatedRecordings;
|
initialPage?: PaginatedRecordings;
|
||||||
}) {
|
}) {
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
|
const [filterUserId, setFilterUserId] = useState<string | null>(null);
|
||||||
const {
|
const {
|
||||||
data: items,
|
data: items,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -40,12 +61,14 @@ export function RecordingsView({
|
|||||||
nextCursor,
|
nextCursor,
|
||||||
hasMore,
|
hasMore,
|
||||||
mutate,
|
mutate,
|
||||||
} = useRecordings(initialPage);
|
} = useRecordings(initialPage, filterUserId ?? undefined);
|
||||||
const loadMore = useLoadMoreRecordings();
|
const loadMore = useLoadMoreRecordings(filterUserId ?? undefined);
|
||||||
const del = useDeleteRecording();
|
const del = useDeleteRecording();
|
||||||
useRecordingsWsSync(ws);
|
useRecordingsWsSync(ws);
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
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
|
// Maximum older pages to prevent infinite runaway memory usage
|
||||||
const MAX_OLDER_PAGES = 10;
|
const MAX_OLDER_PAGES = 10;
|
||||||
@@ -64,6 +87,19 @@ export function RecordingsView({
|
|||||||
ambient.set("signal", 0.3, "recordings");
|
ambient.set("signal", 0.3, "recordings");
|
||||||
}, [ambient]);
|
}, [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 () => {
|
const loadOlder = useCallback(async () => {
|
||||||
if (
|
if (
|
||||||
!hasMore ||
|
!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)
|
if (error && !items)
|
||||||
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
||||||
if (!items && isLoading)
|
if (!items && isLoading)
|
||||||
@@ -138,6 +234,7 @@ export function RecordingsView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const totalRecordings = (items ?? []).length;
|
const totalRecordings = (items ?? []).length;
|
||||||
|
const exportableCount = (items ?? []).filter((r) => r.download_url).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -160,6 +257,47 @@ export function RecordingsView({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<GlassPanel>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
eyebrow="acoustic buffer"
|
eyebrow="acoustic buffer"
|
||||||
@@ -173,8 +311,16 @@ export function RecordingsView({
|
|||||||
{totalRecordings === 0 ? (
|
{totalRecordings === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Headphones className="size-7" />}
|
icon={<Headphones className="size-7" />}
|
||||||
title="Tape deck is empty"
|
title={
|
||||||
description="Voice transmissions captured in connected channels will be archived here."
|
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
|
<div
|
||||||
@@ -188,6 +334,7 @@ export function RecordingsView({
|
|||||||
{(items ?? []).map((r) => {
|
{(items ?? []).map((r) => {
|
||||||
const up = uploadStatus(r);
|
const up = uploadStatus(r);
|
||||||
const isPlaying = playingId === r.id;
|
const isPlaying = playingId === r.id;
|
||||||
|
const isExporting = exportingIds.has(r.id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={r.id}
|
key={r.id}
|
||||||
@@ -264,14 +411,29 @@ export function RecordingsView({
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{r.download_url && (
|
{r.download_url && (
|
||||||
<a
|
<>
|
||||||
href={r.download_url}
|
<button
|
||||||
target="_blank"
|
type="button"
|
||||||
rel="noreferrer"
|
onClick={() => onExportOne(r)}
|
||||||
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"
|
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"
|
||||||
<Download className="size-3 text-signal" /> RAW
|
>
|
||||||
</a>
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -5,18 +5,40 @@ import { recordingsApi } from "@/lib/api";
|
|||||||
import type { PaginatedRecordings, VoiceRecording } from "@/lib/types";
|
import type { PaginatedRecordings, VoiceRecording } from "@/lib/types";
|
||||||
import type { WsHook } from "@/lib/ws-hook";
|
import type { WsHook } from "@/lib/ws-hook";
|
||||||
|
|
||||||
const RECORDINGS_KEY = ["recordings"] as const;
|
const RECORDINGS_PREFIX = "recordings";
|
||||||
|
|
||||||
export function useRecordingsPage(initialPage?: PaginatedRecordings) {
|
function recordingsKey(userId?: string): [string, string] {
|
||||||
return useSWR<PaginatedRecordings>(
|
return [RECORDINGS_PREFIX, userId ?? "all"];
|
||||||
RECORDINGS_KEY,
|
}
|
||||||
() => recordingsApi.list(50),
|
|
||||||
{ fallbackData: initialPage },
|
/** 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) {
|
export function useRecordingsPage(
|
||||||
const page = useRecordingsPage(initialPage);
|
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 {
|
return {
|
||||||
...page,
|
...page,
|
||||||
data: page.data?.items,
|
data: page.data?.items,
|
||||||
@@ -26,21 +48,13 @@ export function useRecordings(initialPage?: PaginatedRecordings) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLoadMoreRecordings() {
|
export function useLoadMoreRecordings(userId?: string) {
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
return useAction(
|
return useAction(
|
||||||
async ({
|
async ({ channelId, cursor }: { channelId?: string; cursor: string }) => {
|
||||||
channelId,
|
|
||||||
userId,
|
|
||||||
cursor,
|
|
||||||
}: {
|
|
||||||
channelId?: string;
|
|
||||||
userId?: string;
|
|
||||||
cursor: string;
|
|
||||||
}) => {
|
|
||||||
const result = await recordingsApi.list(50, channelId, userId, cursor);
|
const result = await recordingsApi.list(50, channelId, userId, cursor);
|
||||||
await mutate(
|
await mutate(
|
||||||
RECORDINGS_KEY,
|
recordingsKey(userId),
|
||||||
(old: PaginatedRecordings | undefined): PaginatedRecordings => {
|
(old: PaginatedRecordings | undefined): PaginatedRecordings => {
|
||||||
if (!old) return result;
|
if (!old) return result;
|
||||||
const existingIds = new Set(old.items.map((r) => r.id));
|
const existingIds = new Set(old.items.map((r) => r.id));
|
||||||
@@ -62,8 +76,9 @@ export function useDeleteRecording() {
|
|||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
return useAction((id: string) => recordingsApi.delete(id), {
|
return useAction((id: string) => recordingsApi.delete(id), {
|
||||||
onSuccess: (_, id) => {
|
onSuccess: (_, id) => {
|
||||||
|
// A deleted recording disappears from every filter view.
|
||||||
void mutate(
|
void mutate(
|
||||||
RECORDINGS_KEY,
|
(key: unknown) => isRecordingsKey(key),
|
||||||
(
|
(
|
||||||
old: PaginatedRecordings | undefined,
|
old: PaginatedRecordings | undefined,
|
||||||
): PaginatedRecordings | undefined => {
|
): PaginatedRecordings | undefined => {
|
||||||
@@ -84,8 +99,9 @@ export function useRecordingsWsSync(ws: WsHook) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = ws.on("voice_recording_uploaded", (data) => {
|
const unsub = ws.on("voice_recording_uploaded", (data) => {
|
||||||
const rec = data as VoiceRecording;
|
const rec = data as VoiceRecording;
|
||||||
|
// A fresh recording should appear in every filter view it belongs to.
|
||||||
void mutate(
|
void mutate(
|
||||||
RECORDINGS_KEY,
|
(key: unknown) => isRecordingsKey(key),
|
||||||
(old: PaginatedRecordings | undefined): PaginatedRecordings => {
|
(old: PaginatedRecordings | undefined): PaginatedRecordings => {
|
||||||
if (!old) return { items: [rec], nextCursor: null, hasMore: false };
|
if (!old) return { items: [rec], nextCursor: null, hasMore: false };
|
||||||
if (old.items.some((r) => r.id === rec.id)) return old;
|
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