refactor: remove voice/recording/media features from frontend + prune lockfiles

- Delete pages: (dashboard)/{recordings,voice,media}/ incl. view.tsx
- Delete components/{voice,media}, hooks/{use-voice,use-recordings,use-media},
  lib/audio/ (mic-transmit, pcm-player, wav), api/{voice,recordings,media},
  types/{voice,recording,media}, lib/hash.ts
- Cut nav tiles (Active Voice Stages, Voice Recording Archive) from dashboard,
  MiniPlayer from ambient-app, hooks/types barrel exports, WS voice/media events
- Re-home guilds + textChannels to messages oRPC router (DB-derived) so the
  messages page picker keeps working; guild-picker simplified to text-only
- Clean Channel/AppConfig types of voice remnants
- Delete infra/docker/recordings/ + 11 voice/recording/video spec docs
- pnpm install: prune direct voice deps from gateway + backend lockfiles
  (prism-media/opusscript remain only as transitive discord.js deps)
This commit is contained in:
asepharyana
2026-09-23 19:45:14 +07:00
parent 33013697e0
commit ce784f8305
55 changed files with 151 additions and 5227 deletions
@@ -1,67 +0,0 @@
# Spec: Perbagus fitur Voice + Audio Playback (GMW frontend)
Tanggal: 2026-08-22 · Scope: **frontend only** (backend/gateway API sudah cukup)
## Masalah (audit)
1. Recordings: semua kartu pakai `<audio controls>` native — tampilan identik,
tidak ada indikasi which-clip-playing / loading / paused, dan N audio bisa
play bareng (overlap).
2. Media view: `thumbnailUrl` dari gateway tidak dipakai; tidak ada visual
"sedang playing" selain disc spin; queue item semua sama tanpa badge up-next.
3. Mini-player (`lib/hooks/use-media-player.tsx`) ada tapi TIDAK PERNAH
dimount → dead code, user tidak lihat status musik di halaman lain.
4. Voice page: `useMicTransmit.setVolume` + `useVoiceListen.setVolume`
tersedia tapi tak ada UI-nya; mic live tidak punya level feedback.
## Desain
### A. RecordingAudioPlayer (baru, `components/voice/recording-audio-player.tsx`)
Custom player menggantikan `<audio controls>`:
- Play/pause button (ikon berubah), spinner saat buffering (`waiting` event).
- Progress bar seekable (click-to-seek) + time label `m:ss / m:ss`.
- Waveform-ish equalizer bars saat playing (CSS animation, reduced-motion safe).
- **Single-playback**: module-level registry `activePlayers` — memainkan satu
clip otomatis pause yang lain.
- Kartu pemilik player aktif dapat highlight border signal + "Now playing" chip.
### B. Recordings view — pasang player baru
- Ganti `<audio>` → `<RecordingAudioPlayer src download_url>`.
- Highlight kartu via state lifted: `playingId` di view, callback `onPlay`.
### C. Media view polish
- Hero: thumbnail (jika `current.thumbnailUrl`) sebagai disc center image;
fallback ListMusic icon. Equalizer bars animasi CSS saat `playing`.
- Queue row pertama: badge "up next"; baris current track diberi ring signal.
- Volume read-only tetap.
### D. MiniPlayer global
- Hapus `lib/hooks/use-media-player.tsx` (dead) — ganti dengan komponen
`components/media/mini-player.tsx` yang subscribe `useMediaState` +
`useMediaWsSync` langsung (SWR cache shared antar route), mounted di
`AppFrame` bawah layar (fixed bottom, hidden di route `/media`).
- Menampilkan: thumbnail kecil/judul, tombol skip/stop, link ke /media.
### E. Voice UI
- Mic live: level meter (Equalizer bars) — mic-transmitter sudah punya worklet;
tambah `getLevel()` via AnalyserNode pada stream (simple RMS) di hook.
- Listen: volume slider (input range) wired ke `listen.setVolume`.
- Mic volume slider wired ke `mic.setVolume`.
## File touched
| File | Aksi |
|---|---|
| services/frontend/src/components/voice/recording-audio-player.tsx | new |
| services/frontend/src/app/(dashboard)/recordings/view.tsx | edit |
| services/frontend/src/app/(dashboard)/media/view.tsx | edit |
| services/frontend/src/components/media/mini-player.tsx | new |
| services/frontend/src/components/shell/ambient-app.tsx | mount MiniPlayer |
| services/frontend/src/lib/hooks/use-media-player.tsx | delete |
| services/frontend/src/hooks/use-voice.ts | tambah micLevel |
| services/frontend/src/lib/audio/mic-transmit.ts | expose analyser level |
| services/frontend/src/app/(dashboard)/voice/view.tsx | sliders + meter |
## Verifikasi
1. `pnpm lint` (biome) + `pnpm build` clean.
2. Smoke di port **4024** (BUKAN 4017) → curl 200 semua route.
3. Commit (tanpa trailer) → push → `gh run watch` → live check
https://imphnen.asepharyana.my.id/{media,recordings,voice}/ = 200.
@@ -1,82 +0,0 @@
# 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,81 +0,0 @@
# Spec: Recordings v2 — transcription, search, filters, leaderboard, sessions
## Konteks
4 fitur lanjutan untuk halaman /recordings (dipilih user):
1. Tampilkan transkripsi + search by kata kunci
2. Filter lanjutan: by channel + rentang tanggal
3. Kelompokkan klip jadi "sesi rapat" + autoplay berurutan + export satu sesi
4. Leaderboard bicara per user + ringkasan
## Fakta terverifikasi (2026-08-30)
- `voice_recordings` kolom: id, user_id, username, avatar_url, guild_id,
channel_id, channel_name, filename, size_bytes, download_url, upload_status,
upload_error, created_at, uploaded_at, **transcription** (schema
`shared/database/schema.ts:246`). Index user_id/channel_id/created_at.
- **Transcription 0/10.640** terisi prod: `AI_VOICE_TRANSCRIPTION_ENABLED`
default **false** (config/index.ts:333) & tidak diset di BWS env →
`transcribeRecording` (voiceTranscriber.ts) langsung return null.
`AI_LLM_BASE_URL` + `AI_LLM_API_KEY` SUDAH dikonfig (Whisper via router GMW).
- Transcriber hardcode `language: "en"` (voiceTranscriber.ts:34) — salah utk
ucapan campur id/en. Utk auto-detect: hapus param `language` (Whisper
auto-detect jika tidak diberikan).
- Backend `RecordingsService.getRecent` (recordings.service.ts) TIDAK select
`transcription`; SUDAH dukung filter `channelId`+`userId`+`cursor`; belum
dukung date-range & keyword search. `RecordingRow` interface juga tak punya
`transcription`.
- Tidak ada kolom `session_id` / `duration_ms` → sesi grouping = heuristik
(channel sama + gap created_at), durasi leaderboard = estimasi dari
size_bytes (MP3 128kbps: durasi_s ≈ size_bytes*8/128000).
## Keputusan desain
1. **Aktifkan transkripsi (fondasi)**: transcriber auto-detect (hapus
`language:"en"`), set secret BWS `AI_VOICE_TRANSCRIPTION_ENABLED=true`
(dibaca runtime oleh bws-exec saat service start). Rekaman BARU dapat
transkripsi. Backfill rekaman lama TIDAK dilakukan (pilih user: fokus baru;
file OGG lama kemungkinan besar sudah tidak dipakai).
2. **Backend** — perluas `getRecent`:
- select `transcription` (+ interface RecordingRow + FE type)
- filter baru: `q` (ILIKE on transcription + username), `startDate`/`endDate`
(created_at range, bigint ms)
- endpoint baru `recordings.summary`: agregasi per user → {user_id,
username, avatar_url, clips, est_duration_s, words, last_at}. `words`
dihitung dari transcription (tokenisasi spasi). Return sorted by clips.
3. **Frontend**:
- Kartu: tampilkan transkripsi (collapse/expand line-clamp) + durasi estimasi.
- Toolbar: search box (q), Select channel, date range (start/end), speaker
(sudah ada), reset filter.
- Tab/segment "Tape Deck" vs "Leaderboard": leaderboard render summary per
user + klik → filter deck by user itu.
- Sesi grouping (deck view): klip di-group jadi sesi bila channel sama &
gap antar klip < SESSION_GAP_MS (default 120s). Header sesi (channel,
waktu mulai, jumlah klip, total durasi). Autoplay tombol "Play session" &
"Export session WAV" (concat klip sesi via lib/audio/wav.ts yg sudah ada).
## Perubahan file
### Gateway (Tahap 1)
- `voiceTranscriber.ts`: hapus baris `language: "en"`.
- (secret) set `AI_VOICE_TRANSCRIPTION_ENABLED=true` via bws.
### Backend (Tahap 2)
- `recordings.service.ts`: interface + select + getRecent tambah transcription;
tambah filter q/startDate/endDate; method getSummary() untuk leaderboard.
- `orpc/router.ts`: procedur `recordings.list` schema tambah fields; prosedur
baru `recordings.summary`.
### Frontend (Tahap 3-5)
- `lib/types/recording.ts`: tambah transcription, est_duration_s opsional,
Summary type.
- `lib/api/recordings.ts`: list tambah q/startDate/endDate; + summary().
- `hooks/use-recordings.ts`: propagate filter baru ke key+call; hook
useRecordingsSummary.
- `app/(dashboard)/recordings/view.tsx`: toolbar search+channel+date, kartu
transkripsi, tab leaderboard, grouping sesi + autoplay + export sesi.
## Verification
- tiap tahap: gateway `pnpm build` + `biome check src/`; backend `pnpm build`
+ `biome check src/ tests/`; FE `pnpm build` + `biome check src/`.
- CI Build & Deploy hijau tiap tahap; deploy landing dicek via
`systemctl show gmw-<svc>.service --property=ActiveEnterTimestamp`.
- Tahap 1c: setelah deploy + rekaman baru, cek
`SELECT COUNT(*) FROM voice_recordings WHERE transcription IS NOT NULL`.
@@ -1,71 +0,0 @@
# Spec: Record video (kamera/screenshare) orang lain — WebRTC receive (Request 2)
Date: 2026-08-30. Status: Phase A + B DONE (capture → playable MP4); Phase C (UI) open.
## Why this is hard (ground truth, verified from @discordjs/voice 0.19.2 source)
`VoiceReceiver.onUdpMessage` (dist/index.mjs:2059) drops EVERY non-opus RTP
packet at line 2068: `if ((msg[1] & 127) !== RTP_OPUS_PAYLOAD_TYPE) return;`.
So video (kamera H264? actually Discord uses VP8/H264; screenshare combines with
video SSRC) is decrypted-capable but never forwarded. `receiver.parsePacket`
(2033) DOES decrypt any payload type generically (audio + video) using
`connectionData.{encryptionMode, nonceBuffer, secretKey}` — the only audio gate
is the opus check inside onUdpMessage.
=> FIX: wrap `receiver.onUdpMessage` (like screenShareAudio.ts already does for
screen-share AUDIO SSRCs): for packets whose payload type is a VIDEO type
(payload 96 VP8, 101/102 H264, 106/116/126/127 AV1, VP9 98...), call
`receiver.parsePacket(...)` myself to decrypt, then depacketize + write frames.
Delegate opus (120) to the original handler. Delegate audio to original.
## Audio already works (screenShareAudio.ts). We add VIDEO.
## Science-of-the-changes below.
## Phase A — capture + decrypt + depacketize to AnnexB h264 (THIS change)
Files (new): `src/modules/voice-recording/videoReceiver.ts`
- Hook into `recorder.startRecording` alongside `hookScreenShareAudio`.
- Wrap `receiver.onUdpMessage`:
- read ssrc = msg.readUInt32BE(8); userData = receiver.ssrcMap.get(ssrc)
- if payload type is video AND we have a "watching" subscription for that user
(videoSSRC present), decrypt via receiver.parsePacket(...), then:
- H264 (101/102 + payload 120 not): strip RTP header, reassemble FU-A
fragments into AnnexB NALs (start-code prefixed), buffer until we have
a full access unit (keyframe SPS/PPS/IDR or slices), append to a per-
user-per-burst `.h264` file.
- else delegate to original onUdpMessage.
- Watch `receiver.ssrcMap` "create"/"update" for `videoSSRC !== undefined` →
signal a video burst started for that user (like screenShareAudio does).
- Per-user video files written to `config.RECORDINGS_DIR/<uid>/video-<ts>.h264`.
- Guard: skip bot's own video (client.user.id).
Dependencies: NO new npm deps for Phase A (only crypto already in
@discordjs/voice via parsePacket + Buffer). ffmpeg-headless (already in Nix
buildInputs) used in Phase B for decode+mux.
## Phase B — decode + mux to playable MP4/WebM (DONE, commit 999c054b)
- `closeBurst` waits for the WriteStream `finish` (full flush/fd close), then
`muxToMp4(rawPath)`: `ffmpeg -f h264 -i raw.h264 -c copy -movflags +faststart
out.mp4`, deletes raw on success (>=1B mp4), keeps it on failure.
- Output: `<RECORDINGS_DIR>/<uid>/video-<ssrc>-<ts>.mp4`.
- ffmpeg is on the gateway runtime PATH (pkgs.ffmpeg-headless, already in the
Nix buildInputs for the music/GoLive players).
- Test: `tests/videoReceiver.test.ts` muxToMp4 case (real ffmpeg, generates a
tiny baseline h264, asserts mp4 non-empty + raw deleted; skipped if no ffmpeg).
## Phase C — frontend playback + session grouping (follow-up)
- Backend oRPC list video files; FE video player, group by call session like audio.
## Verification
- Phase A: join voice, have a member screen-share/camera, confirm `.h264` file
grows with NAL frames + keyframes; journal shows "video burst" logs.
- Run vitest unit: RTP header strip + FU-A reassembly gives correct bytes.
## Open questions / risks
- Discord codec for camera = H264(101/102); screenshare uses H264 (101/103?)
and can also be VP8/VP9. Handle H264 first (depacketize proven), VP8/VP9 in
Phase B via ffmpeg RTP input.
- Encryption: DAVE (dave_protocol_version) adds a session layer; parsePacket
already applies daveSession.decrypt for audio — we must call the SAME
parsePacket path so DAVE/encryption is handled identically.
- ssrc↔user mapping during a broadcast: videoSSRC is in ssrcMap after the
voice state; may need the STREAM_CREATE network events to key reliably.
@@ -1,66 +0,0 @@
# Spec: Voice auto-reconnect (persistent state + rejoin on drop)
## Goal
Setelah `VoiceController.connect()` berhasil, state "sedang merekam di <guild>/<channel>"
disimpan di Postgres. Kalau gateway restart/reboot, atau koneksi voice drop tidak
disengaja (dikeluarkan/moved/server restart), gateway otomatis join ulang ke channel
yang sama.
## Requirement mapping (user's ask)
- "autoreconnect ke channel yg sama jika server restart atau reboot" → reconnect on
startup (ready handler) + keep DB record across graceful shutdown.
- "state nya persistent di db" → `voice_auto_reconnect` table.
- "rejoin jika tidak sengaja dikeluarkan" → watchdog on `Disconnected`/`Destroyed`
(kick / moved / voice server restart) → full rejoin with backoff.
- Manual leave (`/voice disconnect`, dashboard disconnect) MUST NOT rejoin.
## Design decisions
1. **New table** `voice_auto_reconnect` (dedicated, not `ui_state`):
- `guild_id` text PK
- `channel_id` text NOT NULL
- `channel_name` text
- `connected_at` bigint epoch-ms
- `updated_at` bigint epoch-ms
DAO: `voiceAutoReconnectRepo.ts` — `upsert(record)`, `list()`, `delete(guildId)`.
2. **Write on connect**: `VoiceController.connect()` → after `startRecording` success →
`upsert`. IDEMPOTENT (upsert per guild).
3. **Clear on manual leave**: `handleVoiceDisconnect` (all) + `handleVoiceDisconnectGuild`
pass `clearPersisted: true`. Graceful shutdown `disconnect()` keeps the record.
4. **Rejoin on startup**: bootstrap `ready` → `await voiceController.autoReconnect()`
(list persisted → connect each, non-fatal on failure).
5. **Rejoin on unexpected drop**: monitor per-connection; on `Disconnected`/`Destroyed`
with `!intentional` → schedule rejoin `connect(guildId, persistedChannelId)` with
backoff (min 2s, max 30s, max 5 attempts). Track `rejoinAttempts`, reset on success.
6. **Intentional flag**: `disconnectGuild(guildId, { clearPersisted?, intentional? })`.
- shutdown `disconnect()` → `{ intentional: true, clearPersisted: false }`.
- manual `disconnect()` (dashboard) → `{ clearPersisted: true }`, sets intentional.
- manual `disconnectGuild` → `{ clearPersisted: true }`, sets intentional.
The monitor checks `intentional` before rejoin; `clearPersisted` only deletes the row.
## Files touched
- `services/discord-gateway/src/shared/database/schema.ts` — add `pgVoiceAutoReconnectTable`
+ types.
- `services/discord-gateway/src/shared/database/voiceAutoReconnectRepo.ts` (NEW) — DAO.
- `services/discord-gateway/src/modules/voice-recording/voiceController.ts` — upsert on
connect; monitor + rejoin; `autoReconnect()`; `disconnect/disconnectGuild` opts.
- `services/discord-gateway/src/modules/command-handler/voice.handler.ts` — manual
disconnect/disconnectGuild pass `clearPersisted: true`.
- `services/discord-gateway/src/app/bootstrap.ts` — call `voiceController.autoReconnect()`
in `ready`.
- `services/discord-gateway/drizzle/migrations/0020_add_voice_auto_reconnect.sql` +
`meta/_journal.json` entry (apply manually per gmw-ops).
## Edge cases
- Channel deleted / guild lost while persisted → `connect()` throws (channel not found)
→ log + delete persisted row (don't retry forever).
- Rejoin attempts exhausted → keep row (so next restart retries) + log.
- Multiple guilds: per-guild monitor, per-guild persisted row.
- Graceful shutdown order: shutdown sets intentional=true (so no rejoin during teardown)
but keeps row.
## Verification
- `pnpm typecheck && pnpm build && pnpm lint` in `services/discord-gateway`.
- Apply migration `0020` manually; verify table exists.
- CI `Build & Deploy (Nix)` green; gateway deploy lands.
- Manual: connect via dashboard → check `voice_auto_reconnect` row; simulate drop →
confirm rejoin; manual disconnect → row cleared.
@@ -1,166 +0,0 @@
# Spec: Perbaiki alur voice → recording (miss & terpotong)
## Konteks & Gejala
User melaporkan alur voice sampai recording **banyak miss** (audio tidak tercatat)
dan **terpotong** (satu alur bicara kebelah jadi beberapa segmen / audio putus di
tengah). Ini domain `services/discord-gateway/src/modules/voice-recording/`.
Pipeline per user yang mulai bicara (speaking "start"):
```
receiver.speaking "start" → speakingHandler(userId)
├─ await collectUserMetadata(...) ← roundtrip API, subscribe tertunda
├─ receiver.subscribe(userId, {end: AfterSilence, duration: 3000ms}) → audioStream
├─ attach data/end/error handlers → audioStream.pipe(PacketFilter) → oggPacketStream
├─ SegmentManager.open() → OggLogicalBitstream → file .ogg
├─ data: SegmentManager.rotateIfNeeded (rotasi 5s) + decoder.write (web PCM tho
└─ end: SegmentManager.close() → segmen finish → finalizeSegment upload + transkrip
```
## Root cause (dari pembacaan kode — justifikasi di bawah)
### A. MISS bagian awal bicara — subscribe tertunda (utama)
`speakingHandler.ts:53` melakukan `await collectUserMetadata(...)` SEBELUM
`receiver.subscribe`. `collectUserMetadata` (metadata.ts:30) pada cold path
(cache miss) melakukan `client.users.fetch` + `guild.members.fetch` roundtrip
Discord API (ratusan ms–detik). Selama await, seluruh opus awal bocor → awal
kalimat hilang. Cache menghilangkan ini untuk user yang pernah ter-record, tapi
user baru/evict (cache max 200) kena setiap kali.
### B. Double-subscribe race
Guard `receiver.subscriptions.has(userId)` di `speakingHandler.ts:63` diletakkan
SETELAH `await collectUserMetadata`. Dua event "start" cepat keduanya melewati
guard (belum subscribe) → dua subscription → audio terbelah/ganda per user.
### C. TERPOTONG di jeda bicara — AfterSilence 3000ms
`AUDIO_STREAM_SILENCE_DURATION_MS=3000`. Setelah 3s diam, stream auto-`end` →
`SegmentManager.close` → segmen baru saat bicara lagi. Jeda normal (berpikir,
interupsi) memecah 1 alur bicara jadi beberapa segmen/file. Ini source "terpotong".
Segmen pendek hasil jeda <1s juga DIBUANG di `finalizeSegment` (MIN_DURATION_MS=1000)
→ miss kata singkat ("ya", "siap").
### D. Rotasi segmen 5s di tengah bicara
`RECORDING_SEGMENT_MS=5000`: `rotateIfNeeded` menutup bitstream & membuka baru
setiap 5s walau bicara kontinu. Pipenya di-re-wire di dalam handler data →
window drop kecil + continuity file pecah (bukan masalah besar, tapi berkontribusi).
### E. Tidak ada sinkronisasi "stop" speaking & stream "end" flaky
Handler hanya listen "start"; mengandalkan `AfterSilence` untuk emit "end".
Bug @discordjs/voice yang dikenal: `AfterSilence` bisa TIDAK emit "end" saat
koneksi gagal/teardown → segmen menggantung & tidak pernah finalize/upload
(recording "hilang"). Tidak ada watchdog.
## Scope
Hanya `services/discord-gateway/src/modules/voice-recording/` (+ config index bila
perlu default baru). Tidak menyentuh playback (player.ts), transmitter (voice dari
browser → Discord, arah berlawanan), muxer (konsolidasi akhir), atau screen-share
video (hanya audio SSRC via `hookScreenShareAudio` sudah ada & dibiarkan).
## Perubahan
### 1. Speak-before-metadata: subscribe LEBIH DULU, metadata paralel
`recorder/speakingHandler.ts`:
- Pindahkan `receiver.subscribe` + pipeline setup ke ATAS, SEGERA di handler,
sebelum `collectUserMetadata`.
- Jalankan `collectUserMetadata` secara paralel non-blocking; gunakan metadata
cache untuk registrasi segmen saat finalize.
- Pertahankan guard skip bot/user (bot bisa dicek dari `client.users.cache` /
`client.user.id` tanpa await) SEBELUM subscribe — jangan tunggu fetch user.
Detail konkret:
```
async handler(userId):
if (userId === client.user?.id) return;
if (receiver.subscriptions.has(userId)) return; // guard kini di DEPAN, tanpa await
// (belum tahu bot? gunakan cache user; subscribe dulu biar nggak miss)
clone = subscribe(userId, {AfterSilence, duration}) // TANPA await metadata
setup pipeline (data/end/error, pipe, open segment)
collectUserMetadata(...).then(meta => {
if (meta.bot) { drain & close subscription (jangan simpan) }
else { registrasi ulang metadata utk segmen aktif }
})
```
Karena listener `start` dibuang untuk bot, harus tutup subscription bot tanpa
menyimpan segmen (buang hasil). Pakai `receiver.subscriptions.get(userId)?.destroy()`.
### 2. Selesaikan race double-subscribe (bagian dari #1)
Guard `receiver.subscriptions.has(userId)` diletakkan SINCRON di awal (sebelum
await). Karena `subscribe` sinkron dan `subscriptions` terisi sinkron saat
dipanggil, event "start" kedua yang tiba setelah subscribe akan melihat
subscription aktif → di-skip. Tidak ada await antara guard & subscribe.
### 3. Naikkan AfterSilence + tail-length → kurangi "terpotong"
`shared/config/index.ts`:
- `AUDIO_STREAM_SILENCE_DURATION_MS` default 3000 → 4000 (beri ruang jeda
alami; Discord packet 20ms, 4s masih wajar, tidak membengkak file).
Opsional via env override di production (tidak wajib komit env).
### 4. Segmen berorientasi "burst bicara" daripada rotasi jam
`recorder/segment.ts` + `recorder/speakingHandler.ts`:
- Hapus/lepas rotasi SEGMEN berbasis waktu (RECORDING_SEGMENT_MS). Alih-alih,
satu segmen = satu burst bicara (buka di "start", tutup di "end"/AfterSilence
end). Ini menghilangkan pemecahan di tengah kalimat.
- `RECORDING_SEGMENT_MS` tetap dipakai untuk rotasi decoder web-PCM (broadcast
live), di mana segmen besar bisa menunda frame — biarkan seperti ada.
JADI: `SegmentManager.rotateIfNeeded` TIDAK lagi dipanggil pada jalur OGG
recording; decoder rotate tetap dijalankan.
Catatan: dengan satu segmen per burst, ukuran file ~ durasi bicara. File panjang
dibutuhkan transkrip & transcode; tidak ada batas keras yang perlu di-override.
Watchdog di #5 membatasi durasi menggantung.
### 5. Watchdog end-of-burst & teardown recovery
`recorder/speakingHandler.ts`:
- Setelah subscribe, arm timer watchdog (mis. `config twin`/hitung) yang menutup
segmen jika `AfterSilence` tidak emit "end" dalam X detik setelah "stop"
speaking — atau, lebih sederhana & robust: dengarkan BOTH stream "end" DAN
timer dari `receiver.speaking` "stop" (hingga @discordjs/voice meng-klaim
AfterSilence). Bila "stop" fire, mulai countdown kecil (mis. 500ms) lalu
`segmentManager.close` + `decoder.destroy` + destroy subscription jika stream
belum "end".
- Ini menutup A: segmen menggantung → jadi pasti finalize & upload.
Implementasi: subscriptionStream (audioStream) + track milik per-user di
Map<userId, {audioStream, segmentManager, decoder, timer}>; handler "stop"
menjadwalkan finalize.
### 6. Naikkan/ambil MIN segmen duration lebih rendah
`recorder/segmentFinalizer.ts`: MIN_DURATION_MS 1000 → 300ms. Kata pendek
("ya", "siap") tetap tersimpan. GUI biarkan.
## File yang disentuh
- `src/modules/voice-recording/recorder/speakingHandler.ts` (utama: subscribe
first, guard depan, watchdog stop, hapus rotasi segmen dari jalur OGG)
- `src/modules/voice-recording/recorder/segment.ts` (opsional: API close/open,
pertahankan rotate untuk decoder tapi tak dipakai jalur OGG)
- `src/modules/voice-recording/recorder/streamSetup.ts` (kecil: backfill subscribe
supaya return subscription utk cleanup/destroy bot)
- `src/modules/voice-recording/recorder/segmentFinalizer.ts` (MIN_DURATION)
- `src/shared/config/index.ts` (default AfterSilence 4000)
## Yang TIDAK disentuh
- `transmitter.ts` (arah browser→Discord, bukan recording)
- `player.ts`, `mediaSource.ts`, `screenShareAudio.ts` (hook SSRC sudah benar)
- `muxer.ts` (konsolidasi akhir tetap jalan)
- Flake/deps/bundle
## Verification
1. `cd services/discord-gateway && pnpm typecheck` (tsc --noEmit) — 0 error.
2. `pnpm lint` (biome check src/) — exit 0.
3. `pnpm build` (tsc → dist/).
4. Unit test baru (vitest, kalau infra tes ada):
- subscribe terjadi tanpa await metadata (spy urutan panggilan)
- double-start skip lewat guard sinkron
- "stop" → watchdog finalize segmen walau stream tidak "end"
- MIN_DURATION 300ms menyimpan kata pendek
5. Smoke/CI: `nix flake check` (eval). Push → CI `Build & Deploy (Nix)` hijau →
deploy landing (`systemctl show gmw-discord-gateway.service --property=ActiveEnterTimestamp`).
6. Runtime manual (user): join voice, bicara dengan jeda >3s, pastikan 1 alur
kontinu = 1 segmen utuh (bukan 3), dan awal kata tidak hilang.
## Risiko / Trade-off
- Subscribe-before-metadata: burst bot akan dikumpulkan sesaat lalu dibuang
(cost kecil: buang segmen). Lebih baik miss bot daripada miss user.
- AfterSilence naik: file lebih panjang sedikit saat jeda; upload/transkrip
timeout (transcodeToMp3 30s) tetap aman.
- Satu segmen per burst: tidak ada rotasi paksa → durasi segmen = durasi bicara
(bisa menit). Transkrip & transcode tetap ok. Watchdog batasi menggantung.
@@ -1,177 +0,0 @@
# Spec: Receive Others' Screen-Share/Camera Video Under DAVE — Build a DAVE-capable Stream-Watch Connection
Status: **P1–P3 DONE + 4th CRITICAL FIX deployed (f1a7b0c2); DAVE Ready + MLS handshake CONFIRMED live; P4 = waiting on active streamer to confirm video-burst→mp4**
Date: 2026-08-31
Author: Hermes
Related: `docs/specs/2026-08-31_video-receive-eager-selfbot-connection-spec.md` (superseded by this)
`docs/specs/2026-08-31_video-receive-phaseC-spec.md` (Phase C build, selfbot path — dead)
## Problem / Ground truth (established from live logs 2026-08-31)
GMW must record OTHER members' screen-share + camera video in a voice channel it
records. Audio works (via `@discordjs/voice` 0.19.2 negotiating DAVE). Video does
not. Verified live: the selfbot path (`discord.js-selfbot-v13` eager `joinChannel`
→ `joinStreamConnection` → `receiver.createVideoStream`) authenticates but Discord
closes the connection with WS code **4017 "E2EE/DAVE protocol required"** (5x →
`VOICE_CONNECTION_ATTEMPTS_EXCEEDED`). Root cause: **Discord now REQUIRES DAVE
(E2EE) on every voice RTC, and `discord.js-selfbot-v13`'s voice stack predates
DAVE** (identify has no `max_dave_protocol_version`, no MLS handshake). The selfbot
path is dead, cannot be repaired. Full details: skill `gmw-ops` →
`references/video-receive-and-unmute.md` §4.
Facts:
- Watching a stream = a **SEPARATE RTC connection**, not the guild audio socket:
gateway `STREAM_WATCH` (op 20) → Discord replies `STREAM_CREATE` (rtc_server_id)
+ `STREAM_SERVER_UPDATE` (separate token+endpoint) → client opens its own voice
WS+UDP to that endpoint (`StreamConnectionReadonly` in selfbot). The watched
video never rides the @discordjs/voice guild socket.
- The stream-watch RTC ALSO requires DAVE (same 4017 mechanism).
- `@snazzah/davey` (bundled with @discordjs/voice 0.19.2) supports
`MediaType.VIDEO` + `Codec.H264` decrypt — DAVE machinery CAN decrypt H264 video.
- No off-the-shelf DAVE-capable video-RECEIVE path exists. Closing references:
- **Discord-RE/Discord-video-stream** (fork of `@dank074/discord-video-stream`,
master 2026-08-28): full DAVE in `src/client/voice/BaseMediaConnection.ts`
(Davey `DAVESession` init via `initDave`, MLS key-package / proposals /
commit / welcome / transitions; `WebRtcConnWrapper` encrypts audio/video via
`daveSession.encrypt(MediaType.VIDEO, codec, …)`). BUT it is STREAMING only
(send). No STREAM_WATCH / receive.
- `@discordjs/voice`: full DAVE receive for AUDIO only; `DAVESession.decrypt`
hardcodes `MediaType.AUDIO` (dist ~line 892); `onUdpMessage` drops non-opus;
no STREAM_WATCH.
- `discord.js-selfbot-v13`: video receive but no DAVE.
## Goal
Replace the dead selfbot receive path with a **DAVE-capable stream-watch voice
connection**: on detecting a member `voiceState.streaming`, send `STREAM_WATCH`,
connect a DAVE-authenticated RTC to the stream endpoint, decrypt incoming H264
RTP (`MediaType.VIDEO`), reassemble via the existing `H264Depacketizer`, mux to a
playable container. Reuse every tested building block already in the repo.
## Strategy decision (default A; B as fallback) — de-risk in Phase 2
Two implementation routes. Decide by Phase 2 prototype result.
### Strategy A — extend @discordjs/voice's tested native stack (PREFERRED, lighter)
Reuse @discordjs/voice 0.19.2 internals (already a runtime dep, already DAVE-tested
for audio):
- Drive a connection to the stream endpoint using djs/voice's `VoiceWebSocket` +
`VoiceUDPSocket` + `DAVESession` (the same classes that work for the guild
connection — they take arbitrary endpoint/token/session).
- Send the voice identify with `max_dave_protocol_version`, complete the DAVE
handshake (Davey), then on receipt of a video RTP packet call
`daveSession.decrypt(userId, MediaType.VIDEO, packet)` (Davey exposes
`MediaType.VIDEO` + `Codec.H264`) — djs/voice's hardcoded `AUDIO` is the only
blocker, fix by invoking Davey directly with `MediaType.VIDEO` for video SSRCs.
- STREAM_WATCH sent via the existing selfbot `client.ws.broadcast` (cheap, works —
it needs no selfbot voice connection).
- Feed decrypted H264 → `H264Depacketizer` → `.h264` → `muxToMp4` (both already
in `videoReceiver.ts`, unit-tested).
- No new runtime deps. Risk: relies on non-exported djs/voice internals (reachable
via `as any`, as the existing `parsePacket` usage shows).
### Strategy B — port Discord-RE's BaseMediaConnection (heavier, more self-contained)
Port `BaseMediaConnection.ts` DAVE handling + `WebRtcConnWrapper` into a
receive/watch connection in the gateway. Deps: requires `@lng2004/node-datachannel`
(new native WebRTC dep) + `@snazzah/davey` (already available). More code, more
risk (native dep in Nix store), but a clean-room receive path decoupled from
djs/voice internals. Use only if A proves infeasible.
## Files touched (Strategy A shape)
- `services/discord-gateway/src/modules/voice-recording/streamWatchReceiver.ts`
(NEW): DAVE stream-watch connection wrapper. Owns, per watched user:
- `sendStreamWatch(client, streamKey)` (via `client.ws.broadcast({op:20,
d:{stream_key}})`), stream_key = `guild:<gid>:<chid>:<uid>`.
- collects STREAM_CREATE (rtc_server_id) + STREAM_SERVER_UPDATE (token+
endpoint) via `client.on('raw')` match on stream_key.
- builds a djs/voice-style connection to `<endpoint>` with the received token/
session; completes DAVE handshake.
- `onUdpMessage` wrapper: for video payload types, decrypt with
`daveSession.decrypt(userId, MediaType.VIDEO, buf)`, depacketize, write.
- teardown on STREAM_DELETE / user stops streaming / leave / channel untrack.
- `recorder.ts`: wire `trackChannel`/`untrackChannel` already exist; ensure the
selfbot *eager voice connection* attempt is REMOVED (it only 4017-spams logs) —
but KEEP `client.ws.broadcast` availability for STREAM_WATCH.
- `videoRecorder.ts`: remove the dead selfbot `joinChannel`/`joinStreamConnection`
calls; keep the `voiceStateUpdate` streaming detection + teardown bookkeeping as
the entry point; delegate the actual receive to `streamWatchReceiver`.
- `videoReceiver.ts`: keep `H264Depacketizer` + `muxToMp4` (reused). The
guild-socket `hookVideoReceiver` can be removed or left inert.
- Tests: `tests/streamWatchReceiver.test.ts` (DAVE-handshake stub, RTP decrypt path
with a mocked Davey, STREAM_WATCH packet shape); keep `tests/videoReceiver.test.ts`.
## Phases (each independently verifiable)
1. **Phase 1 (this session): spec + source reconnaissance.** Confirm djs/voice
internals are reachable (VoiceWebSocket/VoiceUDPSocket/DAVESession exports &
shapes), confirm Davey `MediaType.VIDEO` decrypt signature, confirm how a raw
stream-watch connection's identify/select-protocol flows. Verify the selfbot
`streamKey` format + `raw` STREAM_CREATE/SERVER_UPDATE payload. GATE: accurate
spec + no unknowns blocking A.
2. **Phase 2: de-risk prototype.** Standalone script (not in the gateway) that:
logs into the same selfbot token, joins a real voice channel, sends STREAM_WATCH
for a live streamer, receives STREAM_CREATE/SERVER_UPDATE, and attempts a
DAVE-authenticated connect + receive of ≥1 H264 packet to prove the path before
any gateway integration. GATE: at least one decrypted H264 NAL captured in the
lab.
3. **Phase 3: gateway integration** per files-touched. GATE: typecheck + build +
biome + unit tests green; CI deploy ok.
4. **Phase 4: live verify.** With a real streamer in a recorded channel: journal
shows `STREAM_WATCH sent`, `DAVE ready`, `Video burst opened`, and a playable
`.h264`/`.mp4`/`.mkv` on disk. GATE: playable file with real video content.
## Risks / open questions
- Does Discord require the stream-watch connection to use the SAME session_id as
the bot's active voice session, or a fresh one? (Affects identify.) Resolve in P2.
- Which video codec does Discord actually send for camera vs GoLive (H264 likely,
but VP9/AV1 possible) — the depacketizer only handles H264. P2 measures the
payload type live; add depacketizers for other codecs only if observed.
- djs/voice `DAVESession`/`VoiceUDPSocket` reachability via `as any` must be
confirmed against the installed 0.19.2 build (P1).
- The separate stream RTC may need `selectProtocol`/SDP even for receive-only; the
Discord-RE SDP shows a `m=video ... inactive` section. Follow the same shape.
## Verification (overall)
- Per-phase gates above.
- No regression: audio recording + message capture still work after changes.
- `pnpm typecheck && pnpm build && pnpm lint` green in discord-gateway.
- Commit + push; CI `Build & Deploy (Nix)` green; live streamer produces a file.
## Phase 1 findings (CONFIRMED 2026-08-31, Strategy A feasible)
- `@discordjs/voice` 0.19.2 dist/index.mjs PUBLICLY exports exactly the primitives
needed: `DAVESession`, `Networking`, `NetworkingStatusCode`, `VoiceConnection`,
`VoiceReceiver`, `VoiceUDPSocket`, `VoiceWebSocket`, `SSRCMap`,
`RTP_OPUS_PAYLOAD_TYPE` (export block ~3143). So a stream-watch connection can be
built OUTSIDE the lib using these constructors — no `as any` needed for the heavy
lifting.
- `Networking` child wiring (~line 1364-1484): `new VoiceWebSocket('wss://' +
endpoint + '?v=8', debug)`; on WS open send Identify `{op, d:{server_id,
user_id, session_id, token, max_dave_protocol_version: getMaxProtocolVersion()}}`;
`createDaveSession(protocolVersion)` → `new DAVESession(protocolVersion, userId,
channelId, {decryptionFailureTolerance})` then `.reinit()`; UDP via
`new VoiceUDPSocket({ip, port})` after Ready gives modes + ssrc.
- `DAVESession` wraps `@snazzah/davey` `Davey.DAVESession(protocolVersion, userId,
channelId)`; on network packets it calls `this.session.decrypt(userId,
Davey.MediaType.AUDIO, packet)` — hardcoded AUDIO (line ~892). For video we call
Davey directly with `MediaType.VIDEO` + `Codec.H264`.
- `@snazzah/davey` MediaType enum: AUDIO=0, VIDEO=1; Codec H264=4; methods
`decrypt(mediaType, codec, packet): Buffer` + `encrypt(...)`. Confirmed in davey
index.d.ts.
- Stream key format (selfbot VoiceConnection.js ~1240): `guild:<gid>:<chid>:<uid>`
for guild channels; `STREAM_WATCH` = gateway op 20, `d:{stream_key}`;
`sendSignalScreenshare` = `client.ws.broadcast({op:20,d:{stream_key}})`. Replies
come as gateway `raw` events `STREAM_CREATE` (`d.rtc_server_id`) +
`STREAM_SERVER_UPDATE` (`d.token`, `d.endpoint`); selfbot routes them to the
stream connection via `client.on('raw')` matching `d.stream_key`, setting
`setSessionId(sessionId)` + `setTokenAndEndpoint(token, endpoint)` (Watch case in
`StreamConnectionReadonly`).
- Discord-RE reference for the identify SDP: stream connections send a `m=video`
section with `a=inactive` (receive-only-ish) + standard DAVE/VoiceOpCodes
(op 0 identify, op 2 select protocol incl. `max_dave_protocol_version`). See
`BaseMediaConnection.handleProtocolAck` + `initDave`.
- Decision: proceed with **Strategy A**. Selfbot code to REMOVE: the eager
`ensureSelfbotVoice` join + `joinStreamConnection`/`receiver.createVideoStream`
in `videoRecorder.ts` (proven dead — 4017). Keep the `voiceStateUpdate` streaming
detection + bookkeeping; swap the receive plumbing to a new `streamWatchReceiver`
driven by a djs/voice-style connection. STREAM_WATCH itself still sent via the
selfbot `client.ws.broadcast` (needs only the WS, not a selfbot voice conn).
- OPEN (resolve in Phase 2 lab): (a) whether the stream connection's identify must
use the bot's ACTIVE voice session_id or a fresh one; (b) actual video codec Discord
sends for camera vs GoLive (measure payload type live; H264 assumed, VP9/AV1 possible
→ add depacketizers only if observed).
@@ -1,93 +0,0 @@
# Spec: Fix Video Capture — Eagerly Establish the Selfbot Voice Connection at Join Time
Status: PLANNED
Date: 2026-08-31
Author: Hermes
Related: `docs/specs/2026-08-31_video-receive-phaseC-spec.md` (Phase C build, made Option A this fix)
## Symptom (from live logs, 2026-08-31 ~12:34)
A user was actively screen-sharing + on camera in the recorded voice channel.
The gateway recorded MANY users' audio (.ogg) fine, but video capture produced
nothing. The only video signal in `journalctl -u gmw-discord-gateway` was:
```
[VOICE (guild:2)]: Sending voice state update: {"self_mute":false,...,"flags":2}
[VOICE] received voice state update: {member hunterz ...} # OTHER user, not bot
[VOICE] connection? true, guild session channel
[VOICE (guild:2)]: Setting sessionId <S> (stored as "undefined")
[VOICE (guild:2)]: Authenticated with sessionId <S> # debug print only
[VOICE (guild:2)]: Authenticate failed - VOICE_CONNECTION_TIMEOUT # +15s
video-recorder: userId=..., "Connection not established within 15 seconds."
```
## Root cause (verified against discord.js-selfbot-v13 3.7.1 source)
The gateway records audio via `@discordjs/voice` (`joinVoiceChannel` + adapter).
Video receive lives on the SEPARATE selfbot `ClientVoiceManager.connection`
(a singleton `VoiceConnection`). `videoRecorder.ts` currently calls
`client.voice.joinChannel(channel)` LAZILY — only when a `voiceStateUpdate`
shows `newState.streaming === true`.
At that moment the bot is ALREADY connected to the channel via @discordjs/voice.
A selfbot `joinChannel` then does `VoiceConnection.authenticate()` →
`sendVoiceStateUpdate()`, and waits for a fresh `VOICE_SERVER_UPDATE`
(`setTokenAndEndpoint`) + `VOICE_STATE_UPDATE` (`setSessionId`) to reach
`checkAuthenticated()` (needs token+endpoint+sessionId). Because the bot is
already in an established voice session, Discord does NOT emit a new
`VOICE_SERVER_UPDATE` for the lazy selfbot re-join → token/endpoint never set →
15s `VOICE_CONNECTION_TIMEOUT`.
This is fatal to video: `joinStreamConnection(userId)` (STREAM_WATCH op 20) and
`receiver.createVideoStream(userId, out)` (Recorder/ffmpeg) BOTH live on the
parent selfbot `VoiceConnection` and require it `CONNECTED` (its own voice
WS+UDP socket feeds `PacketHandler.push`, authenticated with
`authentication.secret_key`).
## Fix — establish the selfbot connection eagerly, at voice-join time
The selfbot `VoiceConnection` must exist and be `CONNECTED` before any streamer
appears. Establish it once, synchronously alongside the @discordjs/voice join in
`recorder.startRecording`, so it rides the bot's FRESH voice join — when Discord
DOES emit VOICE_SERVER_UPDATE. Then cache it and let `videoRecorder` reuse it.
Ordering: in `startRecording`, after the @discordjs/voice `joinVoiceChannel`
returns (and retries) — fire `ensureSelfbotVoice(channel)` best-effort:
1. `await client.voice.joinChannel(channel, { selfMute:false, selfDeaf:false,
selfVideo:false })` (rejects ~VOICE_CONNECTION_TIMEOUT on failure → log +
return null; do NOT block audio).
2. Cache the returned selfbot `VoiceConnection` keyed by guildId.
3. Wire teardown: on `recorder` voice stop / destroyed → `untrackChannel` +
destroy the cached selfbot connection (`disconnect()`).
`videoRecorder.startVideoRecording` then uses the cached selfbot connection:
- If cached & `status === CONNECTED` → use it.
- Else → fall back to a lazy `joinChannel` (still best-effort).
## The two-connection coexistence risk (must verify live)
@discordjs/voice (audio) and the selfbot `VoiceConnection` (video) each open
their OWN low-level voice WS+UDP on the same session. The spec's original
open-question flagged this. Mitigations:
- Clear logging: `Selfbot voice connected (guild=...)`, plus a periodic
`djs/voice status` log so we can confirm audio stays `READY` while the selfbot
connection is up.
- If Discord kicks/breaks the audio connection, logs will show
@discordjs/voice `Disconnected`/reconnect churn — we detect and pivot.
## Files touched
- `services/discord-gateway/src/modules/voice-recording/videoRecorder.ts`:
add `ensureSelfbotVoice(channel)` (return cached/connected), use it in
`startVideoRecording`, add `destroyGuildSelfbotVoice(guildId)`,
richer status logging.
- `services/discord-gateway/src/modules/voice-recording/recorder.ts`: call
`ensureSelfbotVoice(channel)` after `joinVoiceChannel` (best-effort);
call `destroyGuildSelfbotVoice` on voice stop/destroy.
- Tests: `tests/videoRecorder.test.ts` (update to assert eager-connection reuse
+ status gating).
## Verification
1. `pnpm typecheck` + `pnpm build` + biome clean (discord-gateway).
2. Tests green.
3. Commit + push; CI `Build & Deploy (Nix)` green, service restarts.
4. LIVE (deploy): join a channel with the bot → journal shows
`Selfbot voice connected` (parent CONNECTED). When a member streams →
`Sender signal screenshare` / `Video recorder ready` + a `.mkv` under
`<RECORDINGS_DIR>/<uid>/video-*.mkv`; playable via ffmpeg. Confirm audio
recording still flows (no djs/voice reconnect churn).
@@ -1,97 +0,0 @@
# Spec: Record Other Users' Video (Camera / Screen Share) — Phase C
Status: PLANNED (not built)
Date: 2026-08-31
Author: Hermes
Related: `docs/specs/2026-08-30_video-record-receive-spec.md` (Phase A/B — raw UDP hook, superseded for receive)
## TL;DR — what changed vs Phase A/B
Phase A/B (commit `999c054b` etc.) hooked `@discordjs/voice`'s UDP socket to capture non-opus RTP and
depacketize H264 → mp4. **It captured ZERO video** because `@discordjs/voice` never authorizes the bot to
receive others' video (no STREAM_WATCH). This spec replaces that approach with the **native, selfbot-lib
receive path**, which is battle-tested and does the authorization + decryption + ffmpeg muxing for us.
## Ground truth (verified in discord.js-selfbot-v13 3.7.1 source)
1. `ClientVoiceManager.joinChannel(channel, config)` → a **selfbot `VoiceConnection`** with
`.receiver` (`VoiceReceiver` → `PacketHandler`). [ClientVoiceManager.js:102-118]
2. `VoiceConnection.receiver` is created in the constructor. [VoiceConnection.js:140]
3. `VoiceReceiver.createVideoStream(user, output)` → `PacketHandler.makeVideoStream` → **`Recorder`**
(ffmpeg that muxes H264+Opus RTP over UDP → **Matroska (.mkv)**). [Receiver.js, Recorder.js]
4. `PacketHandler` routes: video RTP → Recorder UDP 65506, opus RTP → UDP 65510; decodes all via
`connection.authentication.{secret_key, mode}` (supports `aead_aes256_gcm_rtpsize` and
`aead_xchacha20_poly1305_rtpsize` = DAVE-compatible). [PacketHandler.js:115-155, 195-240]
5. `StreamConnectionReadonly.joinStreamConnection(userId)` + `sendSignalScreenshare()` sends
gateway op `STREAM_WATCH` so Discord actually forwards the streamer's RTP to us. [VoiceConnection.js:1100-1240]
6. `VoiceState.streaming` = `data.self_stream ?? false` — lets us detect a streamer on voice state update. [VoiceState.js:94]
## Problem / the crux
The gateway's voice today is **`@discordjs/voice`** (audio + music + GoLive-send). The selfbot-lib
video-receive path lives on the **selfbot-lib `VoiceConnection`** — a separate voice stack. Two options:
### Option A (RECOMMENDED): Parallel selfbot video-watch connection
Keep `@discordjs/voice` for everything it does today. Add a **second, selfbot-lib voice connection**
to the same channel whose ONLY job is to watch + record others' video.
- Pros: zero regression risk to audio/music/screenshare-send; uses native `createVideoStream` → mk4.
- Cons: two voice connections for the same bot user in one channel. Need to verify Discord tolerates it
(real selfbots like Discord-RE do exactly this for multi-stream). The selfbot lib's `joinChannel`
reuses `ClientVoiceManager.connection` (it's a singleton) — see caveat below.
### Option B: Migrate primary voice to selfbot lib
Make the selfbot `VoiceConnection` THE voice layer (it also does audio via `receiver.createStream`).
- Pros: one connection; video+audio unified.
- Cons: large refactor; high regression risk to the entire existing audio/music/GoLive stack. NOT chosen now.
## CAVEAT — ClientVoiceManager.connection is a singleton
`ClientVoiceManager.connection` is a single `VoiceConnection`. The gateway's `@discordjs/voice` adapter and
the selfbot lib both drive the same client voice state. Need to verify whether `client.voice.joinChannel()`
can coexist with the active `@discordjs/voice` session, or whether we must create the selfbot VoiceConnection
manually / re-use the existing voice state. This is the #1 technical risk to validate in the spike before
committing to Option A.
## Implementation plan (Option A)
### 1. Streamer detector (new: `modules/voice-recording/videoRecorder.ts`)
- Listen to voice state updates (`client.on('voiceStateUpdate')` or the existing voice-state hook).
- When `voiceState.streaming === true` for a member in the bot's channel → candidate to record.
- Skip bot's own user id (unless we also want self-video; default skip).
### 2. Watch + record wiring
- Ensure a selfbot-lib `VoiceConnection` exists for the channel (spike: `client.voice.joinChannel(channel)`,
fallback: build a `VoiceConnection` directly from the existing voice auth).
- `await selfbotVoiceConn.joinStreamConnection(userId)` → STREAM_WATCH op 20.
- `const recorder = selfbotVoiceConn.receiver.createVideoStream(userId, outPath)` where outPath points under
`<RECORDINGS_DIR>/<uid>/video-<streamKey>-<ts>.mkv` (Recorder outputs MKV natively).
- On `recorder.on('ready')` → mark recording; `recorder.on('closed')` → finalize.
- Transcript later: MKV → mp4 via ffmpeg (Phase B `muxToMp4` can accept mkv) for dashboard playback.
### 3. Teardown
- When `voiceState.streaming === false` / user leaves / channel emptied → `recorder.destroy()`,
`selfbotVoiceConn.streamWatchConnection.delete(userId)` / `sendStopScreenshare()`.
### 4. Frontend (Phase UI, later)
- oRPC/backend list `.mkv` per call session + FE `<video>` player (mirror audio recordings UI).
## Files touched
- `services/discord-gateway/src/modules/voice-recording/videoRecorder.ts` (new)
- `services/discord-gateway/src/modules/voice-recording/recorder.ts` (wire streamer detector on voice join)
- Possibly `voiceController.ts` (voice state update subscription)
- Tests: `tests/videoRecorder.test.ts` (mock selfbot VoiceConnection + Recorder)
## Verification
1. `pnpm typecheck` + `pnpm build` + biome clean in discord-gateway.
2. Unit: Recorder wiring + streamer detection with mocked VoiceConnection.
3. Live (deploy): user shares screen → journal shows `STREAM_WATCH` sent + `Recorder ready` + `.mkv` file
appears under recordings dir; playable via ffmpeg.
4. CI Build & Deploy (Nix) green.
## Open questions for spike (before full build)
- [ ] Can `client.voice.joinChannel()` run alongside the active `@discordjs/voice` session, or does the
singleton `ClientVoiceManager.connection` collide / tear down the existing audio connection?
- [ ] Does the selfbot `VoiceConnection` need the bot's `video: true` flag in IDENTIFY to receive video
(it advertises `streams` in IDENTIFY — see BaseMediaConnection/identify vs selfbot VoiceConnection)?
- [ ] Does `Recorder` (spawns system ffmpeg, UDP loopback on 65506/65510) work in the Nix store runtime
(ffmpeg-headless on PATH confirmed; UDP loopback fine)?
@@ -1,33 +0,0 @@
# Video Recording Splitting — Like Voice Recording
## Goal
Camera + screen share (stream watch) recording should split into per-burst
segments just like voice recording does — each time a streamer pauses/stops
and resumes, a new MP4 segment is created and registered in the DB + uploaded.
## Voice Recording Model (to replicate)
1. `receiver.speaking.start` → new OGG segment per burst
2. AfterSilence (4000ms) → stream "end" → segment finalized + uploaded
3. Each segment → DB insert → OGG→MP3 transcode → upload → update DB
4. File stored as `<userId>/<startTime>.ogg` + `.json`
## Video Recording Splitting
1. DAVE video RTP → depacketize H264 → write to current segment .h264
2. Silence detection: no H264 packets for 4000ms → close segment → flush →
mux to MP4 → insert DB record → upload → start new segment on next packet
3. Each segment: `<userId>/video-<channelId>-<startTime>.h264` → `.mp4`
4. DB: reuse `voice_recordings` table (filename indicates video, e.g. `video-XXX-1234.mp4`)
5. Upload: MP4 to TeleUploader (no transcode needed — MP4 plays everywhere)
## Files Modified
- `services/discord-gateway/src/modules/voice-recording/streamWatchReceiver.ts`
— Main change: silence-based splitting + DB registration + upload
## Constants
- `VIDEO_SILENCE_MS = 4000` (matches voice AfterSilence)
- `VIDEO_MIN_SEGMENT_MS = 1000` (skip segments <1s — avoid noise)
## Verification
- `pnpm typecheck` in `services/discord-gateway`
- `pnpm build` (dist/ is the deployed artifact)
- Push → CI deploy → live test with a streamer
@@ -1,81 +0,0 @@
# Spec: Selfbot-Viable Video Capture — manual screen-share watch command (Phase D)
Status: PLANNED (not yet built)
Date: 2026-09-02
Author: Hermes
Related: `docs/specs/2026-08-31_video-receive-phaseC-spec.md` (auto-receive, superseded
for selfbot), `gmw-ops/references/selfbot-presence-detection-limits.md`,
`gmw-ops/references/discord-voice-fork-video-receive.md`
## TL;DR — the decisive finding (verified live 2026-09-02)
User insists on keeping the **selfbot** (no bot-token migration). Live diagnostics prove
a selfbot CANNOT auto-detect other members' camera/share because:
- It never receives `VOICE_STATE_UPDATE` for other members (only its own).
- `guild.members.fetch()` → 403, `GET /channels/{id}/voice-states` → 404.
- No `GUILD_CREATE`, no `READY.broadcaster_user_ids` presence.
- `scanExistingStreamers` + `handleVoiceStateUpdate` (the only two `startStreamWatch`
triggers) are therefore both **dead on a selfbot**.
- No manual watch command exists today, so even on-demand capture is impossible.
→ The ONE selfbot-viable path is a **manual, operator-initiated STREAM_WATCH** on a
member known to be screen-sharing. Gateway op 20 (STREAM_WATCH) is **NOT gated on
bot-vs-user**; the DAVE handshake to Ready+MLS was already verified live in earlier
sessions. The receive/mux/segment/upload pipeline (`streamWatchReceiver.ts`) is already
built and only lacks a real streamer to produce its first `.mp4`.
Camera-of-others is NOT viable on a selfbot even with `unknown-ssrc` fallback:
`@discordjs/voice` `parsePacket` calls `daveSession.decrypt(packet, userId)` keyed per
REAL userId (vendor fork dist/index.js:2143), so a fake id selects no MLS decryptor →
garbage, not H264. (The uncommitted `unknown-ssrc` change was reverted this session.)
Selfbot CAN capture the OWNER's own video (its own VOICE_STATE_UPDATE + fork op12
videoSSRC are attributable), but `videoRecorder.ts` hard-skips its own id — parameterized
self-capture is a follow-up, not the default.
## Goal
Add a **manual watch command** so an operator can say "record <member>'s screen share"
and the gateway `startStreamWatch`s that member → DAVE watch → per-burst `.mp4` segments
(mirroring voice silence split) → upload → DB `voice_recordings` → dashboard `<video>`.
This is the only form of OTHER-member video capture a selfbot can deliver, and it is
genuinely buildable with the existing receive pipeline.
## Scope / files
Gateway (`services/discord-gateway`):
- New command type `VIDEO_WATCH` + handler in `command-handler/` (dedicated
`video.handler.ts`), routed via `createHandlerRegistry`.
- Handler resolves a VoiceChannel (from persisted `voice_auto_reconnect` / active
connections) + target memberId from the command payload, calls
`startStreamWatch(channel, memberId)` (already exported).
- Idempotent (startStreamWatch early-returns if a watch exists); a `VIDEO_UNWATCH`
command calls `stopStreamWatch(guildId, userId)`.
- Reply: success/failure via the standard `CommandReply` publish.
Backend (`services/backend`):
- oRPC procedure (or the existing command bridge) that publishes a `VIDEO_WATCH`
command to `backend:command` with `{ guildId, channelId, userId }`. Reuse the same
bridge the FE already uses for voice commands.
Frontend (`services/frontend`):
- A "Video Watch" control: pick a voice member + a "Record screen" button → calls the
backend procedure. Shows live status (watching / recording / segments uploaded).
(Each layer optional independently; gateway alone gives a Redis-testable path.)
## Verification
1. `pnpm typecheck` + `pnpm build` + `biome check src/` green in discord-gateway.
2. Unit test: handler publishes reply + calls startStreamWatch with the right args
(mock the module).
3. Live: operator invokes `!videorec <member>` while that member screen-shares →
journal shows `Sending STREAM_WATCH` → `STREAM_CREATE` → `DAVE watch READY` → `Video
burst opened` → `Video muxed to mp4` → a `video-*.mp4` appears under
`<recordingsDir>/<uid>/` and a `video-%` row lands in `voice_recordings`.
4. `Build & Deploy (Nix)` CI green.
## Out of scope (documented dead ends on selfbot)
- Auto camera/share capture of OTHER members (impossible at detection layer).
- Camera-of-others via `unknown-ssrc` (DAVE decrypt needs real userId).
- Bot-token migration (user declined).
-3
View File
@@ -1,3 +0,0 @@
*
!.gitkeep
!README.md
View File
-15
View File
@@ -1,15 +0,0 @@
# Recordings Directory
This directory is mounted as a volume in the discord-gateway container.
Voice recordings are stored here with the following structure:
```
recordings/
├── <user-id>/
│ ├── <timestamp>.ogg
│ └── <timestamp>.json
└── sessions/
└── <session-id>.json
```
The directory must be writable by UID 1000 (app user in the container).
+2 -208
View File
@@ -8,9 +8,6 @@ importers:
.:
dependencies:
'@discordjs/voice':
specifier: ^0.19.2
version: 0.19.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
'@orpc/server':
specifier: 1.15.2
version: 1.15.2(@opentelemetry/api@1.9.1)(ws@8.21.3)
@@ -132,10 +129,6 @@ packages:
cpu: [x64]
os: [win32]
'@discordjs/voice@0.19.2':
resolution: {integrity: sha512-3yJ255e4ag3wfZu/DSxeOZK1UtnqNxnspmLaQetGT0pDkThNZoHs+Zg6dgZZ19JEVomXygvfHn9lNpICZuYtEA==}
engines: {node: '>=22.12.0'}
'@emnapi/core@2.0.0-alpha.3':
resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==}
@@ -479,97 +472,6 @@ packages:
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@snazzah/davey-android-arm-eabi@0.1.12':
resolution: {integrity: sha512-6VC/an+Sx5dI5skb+90rYcIB1jhm48Rl0nDaw0UNT4bz1rMjpVfmmZqeocYXMq96IdbBMlE6OTKGcBm2C3gkQg==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
'@snazzah/davey-android-arm64@0.1.12':
resolution: {integrity: sha512-0Bwd03/JsTFhlPhF4q/LW0RxSzntFpQdhz+TBdFljYSg8IEyA38saPJeTNjpIgDfhAumPzvhCdfS6O5qT8yXDw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@snazzah/davey-darwin-arm64@0.1.12':
resolution: {integrity: sha512-lKMV6ITi9BQLt0fx/pAT7M8xcojVK7bryVJGdaW3bq8gABFslS3ti/KzrWabQvhpEV71FZe5mV0UcKHFVaTsZw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@snazzah/davey-darwin-x64@0.1.12':
resolution: {integrity: sha512-vXXc/eW/e3TQeb7VsdtrPqs3/22j0aSqiP1ZXmZtDjQRBwgSxwItWYa6sh5MELP2EHB2igNlGzB6Hc0XlbGi4g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@snazzah/davey-freebsd-x64@0.1.12':
resolution: {integrity: sha512-G1gas5HrC4Xp3mRY0+OeAqXS6fG2tRgBEc8gQh69Hw4YK9RV9mzQKcmoKMkBM72U1+2C+2u57dolnKKzwozByQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@snazzah/davey-linux-arm-gnueabihf@0.1.12':
resolution: {integrity: sha512-97Fujh82r2Ll7dPZeNuoZ3yKfsqycf3c93OWXOo/ThNL/18Onl2Ht4SIvpX6VHhfeS9bDpHJ1lHCaz1b/i5ocw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@snazzah/davey-linux-arm64-gnu@0.1.12':
resolution: {integrity: sha512-FWyAOv52cHKDM4BOsmImKKogHFvqNFoXmZcicNJbX3XpVl8Mas88ZoXQ+IA5V+qc9pNtAl1MbWwEZ9JrqAQtbg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@snazzah/davey-linux-arm64-musl@0.1.12':
resolution: {integrity: sha512-ptRbLSQxtV6EjXppS5z7qaPDI0NRKhrkJYsTlAjEghmOvlAObozSCYYnMO6nbkt6Ab3+lWqyahClzcRNcD2ouw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@snazzah/davey-linux-x64-gnu@0.1.12':
resolution: {integrity: sha512-w86fZvhJn0ErOoAQHt2UbQ95V/cgwvfvQ4GlTPQLCzt58nn+rLlXLgPn90qYlSQrZxFW38rKXwqVOMbm9p+pwQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@snazzah/davey-linux-x64-musl@0.1.12':
resolution: {integrity: sha512-LLNnO+hfG41ymeI+O1YHo5/0h3aKaetUNLdkBwpdJsjoyKMXZaeCnB+aHNkkupJCMPmWS0g6iPMCHUOqZSBcTg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@snazzah/davey-wasm32-wasi@0.1.12':
resolution: {integrity: sha512-MPKFuqYVkDFXheR7qmtEY4FWxQ/ADfgsCojQWHi13sibUqCTR9q2F1LqNn2i9IVh3sh1sxeg87fdFMCH63pl7g==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@snazzah/davey-win32-arm64-msvc@0.1.12':
resolution: {integrity: sha512-Uf4OYHyfbXpzyaOqIV8/h6kv166Qni5+Bxmc1E/ov4uhhKO8qXbOky8zbVOzu0U4cv5ll3s4IQ8jDAJkx/K75Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@snazzah/davey-win32-ia32-msvc@0.1.12':
resolution: {integrity: sha512-nRVbKTsb2ldcPI8D4BDA7P/UeiMEMvR+wYuUMp7H1pRD/3dF2hKo+MzUU+Pn78EhuYA3CWHItiAcS/GsPld67A==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@snazzah/davey-win32-x64-msvc@0.1.12':
resolution: {integrity: sha512-AgUA3itPDVkxQq7RIkgE1thCiWePwWjyfOZefBBxGIlMWpRUOSwF4vY9kNLnrScyJcFULdR+Zm8PwiwV8/RKnw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@snazzah/davey@0.1.12':
resolution: {integrity: sha512-V+NlX5931RwVamZhhEfZekMdcvXDKdMAmHW1AuGaykVQsNyBOq3bpmGpoKRBDCYgFWKIufJ0Dcg3m4cYhvUy6g==}
engines: {node: '>= 10'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -857,9 +759,6 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
discord-api-types@0.38.52:
resolution: {integrity: sha512-uwe9EKfbjsmgWc2fdFjvDbj+dQqx3lp7wqDCmIha0jInuU+xeQjkCK9tMMn+p7RXfdVQORCInq4cD3U2ymDmyg==}
dotenv@18.0.1:
resolution: {integrity: sha512-0eR4m4D/jH5eaI3evo2ZqqMii5mrTCR12v12VChdie6O1gPHs7XAjzTep0CWc1Bd+oNx3vVfbi734dK4zghigw==}
engines: {node: '>=12'}
@@ -1323,23 +1222,6 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
prism-media@1.3.5:
resolution: {integrity: sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==}
peerDependencies:
'@discordjs/opus': '>=0.8.0 <1.0.0'
ffmpeg-static: ^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0
node-opus: ^0.3.3
opusscript: ^0.0.8
peerDependenciesMeta:
'@discordjs/opus':
optional: true
ffmpeg-static:
optional: true
node-opus:
optional: true
opusscript:
optional: true
process-warning@5.1.0:
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
@@ -1663,24 +1545,6 @@ snapshots:
'@biomejs/cli-win32-x64@2.5.14':
optional: true
'@discordjs/voice@0.19.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)':
dependencies:
'@snazzah/davey': 0.1.12(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
'@types/ws': 8.18.1
discord-api-types: 0.38.52
prism-media: 1.3.5
tslib: 2.8.1
ws: 8.21.3
transitivePeerDependencies:
- '@discordjs/opus'
- '@emnapi/core'
- '@emnapi/runtime'
- bufferutil
- ffmpeg-static
- node-opus
- opusscript
- utf-8-validate
'@emnapi/core@2.0.0-alpha.3':
dependencies:
'@emnapi/wasi-threads': 2.0.1
@@ -1941,73 +1805,6 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {}
'@snazzah/davey-android-arm-eabi@0.1.12':
optional: true
'@snazzah/davey-android-arm64@0.1.12':
optional: true
'@snazzah/davey-darwin-arm64@0.1.12':
optional: true
'@snazzah/davey-darwin-x64@0.1.12':
optional: true
'@snazzah/davey-freebsd-x64@0.1.12':
optional: true
'@snazzah/davey-linux-arm-gnueabihf@0.1.12':
optional: true
'@snazzah/davey-linux-arm64-gnu@0.1.12':
optional: true
'@snazzah/davey-linux-arm64-musl@0.1.12':
optional: true
'@snazzah/davey-linux-x64-gnu@0.1.12':
optional: true
'@snazzah/davey-linux-x64-musl@0.1.12':
optional: true
'@snazzah/davey-wasm32-wasi@0.1.12(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)':
dependencies:
'@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
'@snazzah/davey-win32-arm64-msvc@0.1.12':
optional: true
'@snazzah/davey-win32-ia32-msvc@0.1.12':
optional: true
'@snazzah/davey-win32-x64-msvc@0.1.12':
optional: true
'@snazzah/davey@0.1.12(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)':
optionalDependencies:
'@snazzah/davey-android-arm-eabi': 0.1.12
'@snazzah/davey-android-arm64': 0.1.12
'@snazzah/davey-darwin-arm64': 0.1.12
'@snazzah/davey-darwin-x64': 0.1.12
'@snazzah/davey-freebsd-x64': 0.1.12
'@snazzah/davey-linux-arm-gnueabihf': 0.1.12
'@snazzah/davey-linux-arm64-gnu': 0.1.12
'@snazzah/davey-linux-arm64-musl': 0.1.12
'@snazzah/davey-linux-x64-gnu': 0.1.12
'@snazzah/davey-linux-x64-musl': 0.1.12
'@snazzah/davey-wasm32-wasi': 0.1.12(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
'@snazzah/davey-win32-arm64-msvc': 0.1.12
'@snazzah/davey-win32-ia32-msvc': 0.1.12
'@snazzah/davey-win32-x64-msvc': 0.1.12
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
'@standard-schema/spec@1.1.0': {}
'@tybys/wasm-util@0.10.3':
@@ -2233,8 +2030,6 @@ snapshots:
detect-libc@2.1.2: {}
discord-api-types@0.38.52: {}
dotenv@18.0.1: {}
drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0):
@@ -2618,8 +2413,6 @@ snapshots:
dependencies:
xtend: 4.0.2
prism-media@1.3.5: {}
process-warning@5.1.0: {}
prom-client@15.1.3:
@@ -2787,7 +2580,8 @@ snapshots:
toidentifier@1.0.1: {}
tslib@2.8.1: {}
tslib@2.8.1:
optional: true
tsx@4.23.13:
dependencies:
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: set this to true or false
@@ -522,6 +522,44 @@ export class MessagesRepository {
username: r.username ? String(r.username) : null,
}));
}
/**
* Distinct guilds present in the message archive (drives the guild picker).
*/
async listGuilds(): Promise<
Array<{ id: string; name: string; icon: string | null }>
> {
const db = getDatabase();
const rows = await db
.selectDistinct({ guild_id: pgMessagesTable.guild_id })
.from(pgMessagesTable)
.orderBy(pgMessagesTable.guild_id);
return rows.map((row) => ({
id: String(row.guild_id ?? ""),
name: `Guild ${String(row.guild_id).slice(0, 8)}`,
icon: null,
}));
}
/**
* Text channels for a guild, derived from the message archive
* (drives the channel picker).
*/
async listTextChannels(
guildId: string,
): Promise<Array<{ id: string; name: string; type: "text" }>> {
const db = getDatabase();
const rows = await db
.selectDistinct({ channel_id: pgMessagesTable.channel_id })
.from(pgMessagesTable)
.where(eq(pgMessagesTable.guild_id, guildId))
.orderBy(pgMessagesTable.channel_id);
return rows.map((row) => ({
id: String(row.channel_id ?? ""),
name: `Channel ${String(row.channel_id).slice(0, 8)}`,
type: "text" as const,
}));
}
}
export const messagesRepository = new MessagesRepository();
@@ -119,6 +119,16 @@ export class MessagesService {
logger.debug({ limit, channelId }, "Getting recent message edits");
return messagesRepository.getRecentEdits(limit, channelId);
}
/** Distinct guilds present in the message archive (guild picker). */
async getGuilds() {
return messagesRepository.listGuilds();
}
/** Text channels for a guild (channel picker). */
async getTextChannels(guildId: string) {
return messagesRepository.listTextChannels(guildId);
}
}
/** Shape returned to the frontend (text + rich metadata from the archive payload). */
+4
View File
@@ -69,6 +69,10 @@ const dashboardRouter = {
// ── Messages ─────────────────────────────────────────────────────
const messagesRouter = {
guilds: os.handler(() => messagesService.getGuilds()),
textChannels: os
.input(z.object({ guildId: z.string() }))
.handler(({ input }) => messagesService.getTextChannels(input.guildId)),
list: os
.input(messageQuerySchema)
.handler(({ input }) => messagesService.listMessages(input)),
+74 -280
View File
@@ -8,15 +8,6 @@ importers:
.:
dependencies:
'@discordjs/opus':
specifier: ^0.10.0
version: 0.10.0(supports-color@7.2.0)
'@discordjs/voice':
specifier: file:vendor/discord-voice-fork
version: file:vendor/discord-voice-fork(@discordjs/opus@0.10.0(supports-color@7.2.0))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(opusscript@0.1.1)
'@snazzah/davey':
specifier: ^0.1.11
version: 0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
'@typesafe-ai/sdk':
specifier: ^0.6.0
version: 0.6.0
@@ -25,7 +16,7 @@ importers:
version: 1.20.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)
discord.js-selfbot-v13:
specifier: ^3.7.1
version: 3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1)(supports-color@7.2.0)
version: 3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(supports-color@7.2.0)
dotenv:
specifier: ^18.0.0
version: 18.0.1
@@ -35,18 +26,12 @@ importers:
ioredis:
specifier: ^6.0.0
version: 6.0.0(supports-color@7.2.0)
libsodium-wrappers:
specifier: ^0.8.4
version: 0.8.4
lru-cache:
specifier: ^11.5.3
version: 11.5.3
openai:
specifier: ^7.19.0
version: 7.19.0(undici@7.29.0)(ws@8.21.3)(zod@4.6.5)
opusscript:
specifier: ^0.1.1
version: 0.1.1
p-limit:
specifier: ^7.3.3
version: 7.3.3
@@ -62,9 +47,6 @@ importers:
piscina:
specifier: ^5.3.2
version: 5.3.2
prism-media:
specifier: 2.0.0-alpha.0
version: 2.0.0-alpha.0
sharp:
specifier: ^0.35.4
version: 0.35.4(@types/node@26.4.0)
@@ -186,19 +168,12 @@ packages:
resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==}
engines: {node: '>=18'}
'@discordjs/voice@file:vendor/discord-voice-fork':
resolution: {directory: vendor/discord-voice-fork, type: directory}
engines: {node: '>=22.12.0'}
'@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
'@emnapi/core@1.11.1':
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
'@emnapi/core@1.11.3':
resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==}
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
@@ -208,9 +183,6 @@ packages:
'@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
'@emnapi/wasi-threads@1.2.3':
resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==}
'@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.hirok.io'
@@ -1088,97 +1060,6 @@ packages:
resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==}
engines: {node: '>=v16'}
'@snazzah/davey-android-arm-eabi@0.1.12':
resolution: {integrity: sha512-6VC/an+Sx5dI5skb+90rYcIB1jhm48Rl0nDaw0UNT4bz1rMjpVfmmZqeocYXMq96IdbBMlE6OTKGcBm2C3gkQg==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
'@snazzah/davey-android-arm64@0.1.12':
resolution: {integrity: sha512-0Bwd03/JsTFhlPhF4q/LW0RxSzntFpQdhz+TBdFljYSg8IEyA38saPJeTNjpIgDfhAumPzvhCdfS6O5qT8yXDw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@snazzah/davey-darwin-arm64@0.1.12':
resolution: {integrity: sha512-lKMV6ITi9BQLt0fx/pAT7M8xcojVK7bryVJGdaW3bq8gABFslS3ti/KzrWabQvhpEV71FZe5mV0UcKHFVaTsZw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@snazzah/davey-darwin-x64@0.1.12':
resolution: {integrity: sha512-vXXc/eW/e3TQeb7VsdtrPqs3/22j0aSqiP1ZXmZtDjQRBwgSxwItWYa6sh5MELP2EHB2igNlGzB6Hc0XlbGi4g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@snazzah/davey-freebsd-x64@0.1.12':
resolution: {integrity: sha512-G1gas5HrC4Xp3mRY0+OeAqXS6fG2tRgBEc8gQh69Hw4YK9RV9mzQKcmoKMkBM72U1+2C+2u57dolnKKzwozByQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@snazzah/davey-linux-arm-gnueabihf@0.1.12':
resolution: {integrity: sha512-97Fujh82r2Ll7dPZeNuoZ3yKfsqycf3c93OWXOo/ThNL/18Onl2Ht4SIvpX6VHhfeS9bDpHJ1lHCaz1b/i5ocw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@snazzah/davey-linux-arm64-gnu@0.1.12':
resolution: {integrity: sha512-FWyAOv52cHKDM4BOsmImKKogHFvqNFoXmZcicNJbX3XpVl8Mas88ZoXQ+IA5V+qc9pNtAl1MbWwEZ9JrqAQtbg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@snazzah/davey-linux-arm64-musl@0.1.12':
resolution: {integrity: sha512-ptRbLSQxtV6EjXppS5z7qaPDI0NRKhrkJYsTlAjEghmOvlAObozSCYYnMO6nbkt6Ab3+lWqyahClzcRNcD2ouw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@snazzah/davey-linux-x64-gnu@0.1.12':
resolution: {integrity: sha512-w86fZvhJn0ErOoAQHt2UbQ95V/cgwvfvQ4GlTPQLCzt58nn+rLlXLgPn90qYlSQrZxFW38rKXwqVOMbm9p+pwQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@snazzah/davey-linux-x64-musl@0.1.12':
resolution: {integrity: sha512-LLNnO+hfG41ymeI+O1YHo5/0h3aKaetUNLdkBwpdJsjoyKMXZaeCnB+aHNkkupJCMPmWS0g6iPMCHUOqZSBcTg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@snazzah/davey-wasm32-wasi@0.1.12':
resolution: {integrity: sha512-MPKFuqYVkDFXheR7qmtEY4FWxQ/ADfgsCojQWHi13sibUqCTR9q2F1LqNn2i9IVh3sh1sxeg87fdFMCH63pl7g==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@snazzah/davey-win32-arm64-msvc@0.1.12':
resolution: {integrity: sha512-Uf4OYHyfbXpzyaOqIV8/h6kv166Qni5+Bxmc1E/ov4uhhKO8qXbOky8zbVOzu0U4cv5ll3s4IQ8jDAJkx/K75Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@snazzah/davey-win32-ia32-msvc@0.1.12':
resolution: {integrity: sha512-nRVbKTsb2ldcPI8D4BDA7P/UeiMEMvR+wYuUMp7H1pRD/3dF2hKo+MzUU+Pn78EhuYA3CWHItiAcS/GsPld67A==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@snazzah/davey-win32-x64-msvc@0.1.12':
resolution: {integrity: sha512-AgUA3itPDVkxQq7RIkgE1thCiWePwWjyfOZefBBxGIlMWpRUOSwF4vY9kNLnrScyJcFULdR+Zm8PwiwV8/RKnw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@snazzah/davey@0.1.12':
resolution: {integrity: sha512-V+NlX5931RwVamZhhEfZekMdcvXDKdMAmHW1AuGaykVQsNyBOq3bpmGpoKRBDCYgFWKIufJ0Dcg3m4cYhvUy6g==}
engines: {node: '>= 10'}
'@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
@@ -1601,9 +1482,6 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
duplex-child-process@1.0.1:
resolution: {integrity: sha512-tWbt4tyioDjyK5nh+qicbdvBvNjSXsTUF5zKUwSauuKPg1mokjwn/HezwfvWhh6hXoLdgetY+ZlzU/sMwUMJkg==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -1772,12 +1650,6 @@ packages:
resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==}
engines: {node: '>=16'}
libsodium-wrappers@0.8.4:
resolution: {integrity: sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==}
libsodium@0.8.4:
resolution: {integrity: sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==}
lightningcss-android-arm64@1.33.0:
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
engines: {node: '>= 12.0.0'}
@@ -1978,9 +1850,6 @@ packages:
zod:
optional: true
opusscript@0.1.1:
resolution: {integrity: sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==}
otplib@12.0.1:
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
@@ -2108,9 +1977,6 @@ packages:
opusscript:
optional: true
prism-media@2.0.0-alpha.0:
resolution: {integrity: sha512-QL9rnO4xo0grgj7ptsA+AzSCYLirGWM4+ZcyboFmbkYHSgaXIESzHq/SXNizz2iHIfuM2og0cPhmSnTVMeFjKg==}
process-warning@5.1.0:
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
@@ -2539,6 +2405,7 @@ snapshots:
transitivePeerDependencies:
- encoding
- supports-color
optional: true
'@discordjs/opus@0.10.0(supports-color@7.2.0)':
dependencies:
@@ -2547,29 +2414,12 @@ snapshots:
transitivePeerDependencies:
- encoding
- supports-color
optional: true
'@discordjs/util@1.2.0':
dependencies:
discord-api-types: 0.38.52
'@discordjs/voice@file:vendor/discord-voice-fork(@discordjs/opus@0.10.0(supports-color@7.2.0))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(opusscript@0.1.1)':
dependencies:
'@snazzah/davey': 0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
'@types/ws': 8.18.1
discord-api-types: 0.38.52
prism-media: 1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1)
tslib: 2.8.1
ws: 8.21.3
transitivePeerDependencies:
- '@discordjs/opus'
- '@emnapi/core'
- '@emnapi/runtime'
- bufferutil
- ffmpeg-static
- node-opus
- opusscript
- utf-8-validate
'@drizzle-team/brocli@0.10.2': {}
'@emnapi/core@1.11.1':
@@ -2578,12 +2428,6 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/core@1.11.3':
dependencies:
'@emnapi/wasi-threads': 1.2.3
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.11.1':
dependencies:
tslib: 2.8.1
@@ -2599,11 +2443,6 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.2.3':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild-kit/core-utils@3.3.2':
dependencies:
esbuild: 0.18.20
@@ -3032,13 +2871,6 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
'@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)':
dependencies:
'@emnapi/core': 1.11.3
'@emnapi/runtime': 1.11.3
'@tybys/wasm-util': 0.10.3
optional: true
'@otplib/core@12.0.1': {}
'@otplib/plugin-crypto@12.0.1':
@@ -3124,73 +2956,6 @@ snapshots:
fast-deep-equal: 3.1.3
lodash: 4.18.1
'@snazzah/davey-android-arm-eabi@0.1.12':
optional: true
'@snazzah/davey-android-arm64@0.1.12':
optional: true
'@snazzah/davey-darwin-arm64@0.1.12':
optional: true
'@snazzah/davey-darwin-x64@0.1.12':
optional: true
'@snazzah/davey-freebsd-x64@0.1.12':
optional: true
'@snazzah/davey-linux-arm-gnueabihf@0.1.12':
optional: true
'@snazzah/davey-linux-arm64-gnu@0.1.12':
optional: true
'@snazzah/davey-linux-arm64-musl@0.1.12':
optional: true
'@snazzah/davey-linux-x64-gnu@0.1.12':
optional: true
'@snazzah/davey-linux-x64-musl@0.1.12':
optional: true
'@snazzah/davey-wasm32-wasi@0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)':
dependencies:
'@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
'@snazzah/davey-win32-arm64-msvc@0.1.12':
optional: true
'@snazzah/davey-win32-ia32-msvc@0.1.12':
optional: true
'@snazzah/davey-win32-x64-msvc@0.1.12':
optional: true
'@snazzah/davey@0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)':
optionalDependencies:
'@snazzah/davey-android-arm-eabi': 0.1.12
'@snazzah/davey-android-arm64': 0.1.12
'@snazzah/davey-darwin-arm64': 0.1.12
'@snazzah/davey-darwin-x64': 0.1.12
'@snazzah/davey-freebsd-x64': 0.1.12
'@snazzah/davey-linux-arm-gnueabihf': 0.1.12
'@snazzah/davey-linux-arm64-gnu': 0.1.12
'@snazzah/davey-linux-arm64-musl': 0.1.12
'@snazzah/davey-linux-x64-gnu': 0.1.12
'@snazzah/davey-linux-x64-musl': 0.1.12
'@snazzah/davey-wasm32-wasi': 0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
'@snazzah/davey-win32-arm64-msvc': 0.1.12
'@snazzah/davey-win32-ia32-msvc': 0.1.12
'@snazzah/davey-win32-x64-msvc': 0.1.12
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
'@tybys/wasm-util@0.10.3':
dependencies:
tslib: 2.8.1
@@ -3298,7 +3063,8 @@ snapshots:
'@vitest/spy@5.0.1': {}
abbrev@1.1.1: {}
abbrev@1.1.1:
optional: true
agent-base@6.0.2(supports-color@7.2.0):
dependencies:
@@ -3312,12 +3078,14 @@ snapshots:
dependencies:
color-convert: 2.0.1
aproba@2.1.0: {}
aproba@2.1.0:
optional: true
are-we-there-yet@2.0.0:
dependencies:
delegates: 1.0.0
readable-stream: 3.6.2
optional: true
assertion-error@2.0.1: {}
@@ -3335,7 +3103,8 @@ snapshots:
- debug
- supports-color
balanced-match@1.0.2: {}
balanced-match@1.0.2:
optional: true
base64-js@1.5.1: {}
@@ -3343,6 +3112,7 @@ snapshots:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
optional: true
buffer-from@1.1.2: {}
@@ -3365,7 +3135,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
chownr@2.0.0: {}
chownr@2.0.0:
optional: true
cliui@6.0.0:
dependencies:
@@ -3381,7 +3152,8 @@ snapshots:
color-name@1.1.4: {}
color-support@1.1.3: {}
color-support@1.1.3:
optional: true
combined-stream@1.0.8:
dependencies:
@@ -3389,9 +3161,11 @@ snapshots:
commander@14.0.3: {}
concat-map@0.0.1: {}
concat-map@0.0.1:
optional: true
console-control-strings@1.1.0: {}
console-control-strings@1.1.0:
optional: true
debug@4.4.0(supports-color@7.2.0):
dependencies:
@@ -3409,7 +3183,8 @@ snapshots:
delayed-stream@1.0.0: {}
delegates@1.0.0: {}
delegates@1.0.0:
optional: true
denque@2.1.0: {}
@@ -3419,7 +3194,7 @@ snapshots:
discord-api-types@0.38.52: {}
discord.js-selfbot-v13@3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1)(supports-color@7.2.0):
discord.js-selfbot-v13@3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
'@discordjs/builders': 1.14.1
'@discordjs/collection': 2.1.1
@@ -3429,7 +3204,7 @@ snapshots:
fetch-cookie: 3.2.0
find-process: 2.1.1
otplib: 12.0.1
prism-media: 1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1)
prism-media: 1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0))
qrcode: 1.5.4
tough-cookie: 5.1.2
tree-kill: 1.2.2
@@ -3465,8 +3240,6 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
duplex-child-process@1.0.1: {}
emoji-regex@8.0.0: {}
es-define-property@1.0.1: {}
@@ -3612,8 +3385,10 @@ snapshots:
fs-minipass@2.1.0:
dependencies:
minipass: 3.3.6
optional: true
fs.realpath@1.0.0: {}
fs.realpath@1.0.0:
optional: true
fsevents@2.3.3:
optional: true
@@ -3631,6 +3406,7 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wide-align: 1.1.5
optional: true
get-caller-file@2.0.5: {}
@@ -3664,6 +3440,7 @@ snapshots:
minimatch: 3.1.5
once: 1.4.0
path-is-absolute: 1.0.1
optional: true
gopd@1.2.0: {}
@@ -3675,7 +3452,8 @@ snapshots:
dependencies:
has-symbols: 1.1.0
has-unicode@2.0.1: {}
has-unicode@2.0.1:
optional: true
hasown@2.0.4:
dependencies:
@@ -3694,8 +3472,10 @@ snapshots:
dependencies:
once: 1.4.0
wrappy: 1.0.2
optional: true
inherits@2.0.4: {}
inherits@2.0.4:
optional: true
ioredis@6.0.0(supports-color@7.2.0):
dependencies:
@@ -3712,12 +3492,6 @@ snapshots:
is-network-error@1.3.2: {}
libsodium-wrappers@0.8.4:
dependencies:
libsodium: 0.8.4
libsodium@0.8.4: {}
lightningcss-android-arm64@1.33.0:
optional: true
@@ -3784,6 +3558,7 @@ snapshots:
make-dir@3.1.0:
dependencies:
semver: 6.3.1
optional: true
math-intrinsics@1.1.0: {}
@@ -3801,33 +3576,41 @@ snapshots:
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.18
optional: true
minipass@3.3.6:
dependencies:
yallist: 4.0.0
optional: true
minipass@5.0.0: {}
minipass@5.0.0:
optional: true
minizlib@2.1.2:
dependencies:
minipass: 3.3.6
yallist: 4.0.0
optional: true
mkdirp@1.0.4: {}
mkdirp@1.0.4:
optional: true
ms@2.1.3: {}
nanoid@3.3.16: {}
node-addon-api@8.9.0: {}
node-addon-api@8.9.0:
optional: true
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
optional: true
nopt@5.0.0:
dependencies:
abbrev: 1.1.1
optional: true
npmlog@5.0.1:
dependencies:
@@ -3835,8 +3618,10 @@ snapshots:
console-control-strings: 1.1.0
gauge: 3.0.2
set-blocking: 2.0.0
optional: true
object-assign@4.1.1: {}
object-assign@4.1.1:
optional: true
obug@2.1.4: {}
@@ -3845,6 +3630,7 @@ snapshots:
once@1.4.0:
dependencies:
wrappy: 1.0.2
optional: true
openai@7.19.0(undici@7.29.0)(ws@8.21.3)(zod@4.6.5):
optionalDependencies:
@@ -3852,8 +3638,6 @@ snapshots:
ws: 8.21.3
zod: 4.6.5
opusscript@0.1.1: {}
otplib@12.0.1:
dependencies:
'@otplib/core': 12.0.1
@@ -3880,7 +3664,8 @@ snapshots:
path-exists@4.0.0: {}
path-is-absolute@1.0.1: {}
path-is-absolute@1.0.1:
optional: true
pg-cloudflare@1.4.0:
optional: true
@@ -3963,14 +3748,9 @@ snapshots:
dependencies:
xtend: 4.0.2
prism-media@1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1):
prism-media@1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0)):
optionalDependencies:
'@discordjs/opus': 0.10.0(supports-color@7.2.0)
opusscript: 0.1.1
prism-media@2.0.0-alpha.0:
dependencies:
duplex-child-process: 1.0.1
process-warning@5.1.0: {}
@@ -3989,6 +3769,7 @@ snapshots:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
optional: true
real-require@0.2.0: {}
@@ -4005,6 +3786,7 @@ snapshots:
rimraf@3.0.2:
dependencies:
glob: 7.2.3
optional: true
rolldown@1.1.5:
dependencies:
@@ -4027,11 +3809,13 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.1.5
'@rolldown/binding-win32-x64-msvc': 1.1.5
safe-buffer@5.2.1: {}
safe-buffer@5.2.1:
optional: true
safe-stable-stringify@2.5.0: {}
semver@6.3.1: {}
semver@6.3.1:
optional: true
semver@7.8.5: {}
@@ -4074,7 +3858,8 @@ snapshots:
siginfo@2.0.0: {}
signal-exit@3.0.7: {}
signal-exit@3.0.7:
optional: true
sonic-boom@4.2.1:
dependencies:
@@ -4106,6 +3891,7 @@ snapshots:
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
optional: true
strip-ansi@6.0.1:
dependencies:
@@ -4123,6 +3909,7 @@ snapshots:
minizlib: 2.1.2
mkdirp: 1.0.4
yallist: 4.0.0
optional: true
thirty-two@1.0.2: {}
@@ -4161,7 +3948,8 @@ snapshots:
dependencies:
tldts: 7.4.9
tr46@0.0.3: {}
tr46@0.0.3:
optional: true
tree-kill@1.2.2: {}
@@ -4202,7 +3990,8 @@ snapshots:
undici@7.29.0: {}
util-deprecate@1.0.2: {}
util-deprecate@1.0.2:
optional: true
vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13):
dependencies:
@@ -4238,7 +4027,8 @@ snapshots:
transitivePeerDependencies:
- msw
webidl-conversions@3.0.1: {}
webidl-conversions@3.0.1:
optional: true
werift-rtp@0.8.9(supports-color@7.2.0):
dependencies:
@@ -4252,6 +4042,7 @@ snapshots:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
optional: true
which-module@2.0.1: {}
@@ -4263,6 +4054,7 @@ snapshots:
wide-align@1.1.5:
dependencies:
string-width: 4.2.3
optional: true
wrap-ansi@6.2.0:
dependencies:
@@ -4270,7 +4062,8 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wrappy@1.0.2: {}
wrappy@1.0.2:
optional: true
ws@8.21.3: {}
@@ -4278,7 +4071,8 @@ snapshots:
y18n@4.0.3: {}
yallist@4.0.0: {}
yallist@4.0.0:
optional: true
yargs-parser@18.1.3:
dependencies:
@@ -3,7 +3,6 @@
import {
Activity,
AlertTriangle,
AudioWaveform,
ChevronRight,
Hash,
MessageSquare,
@@ -422,16 +421,6 @@ export function DashboardView({
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
<Link
href="/voice"
className="linear-tile hud-card flex items-center justify-between p-3 text-xs text-ink-soft transition-colors hover:text-ink"
>
<span className="flex items-center gap-2">
<AudioWaveform className="size-3.5 text-success" />
Active Voice Stages
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
<Link
href="/analysis"
className="linear-tile hud-card flex items-center justify-between p-3 text-xs text-ink-soft transition-colors hover:text-ink"
@@ -442,16 +431,6 @@ export function DashboardView({
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
<Link
href="/recordings"
className="linear-tile hud-card flex items-center justify-between p-3 text-xs text-ink-soft transition-colors hover:text-ink"
>
<span className="flex items-center gap-2">
<Mic className="size-3.5 text-vermilion" />
Voice Recording Archive
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
</div>
</div>
</PageTransition>
@@ -1,14 +0,0 @@
import { getMediaStatus } from "@/lib/api/server";
import { MediaView } from "./view";
export const dynamic = "force-dynamic";
export default async function MediaPage() {
let status: import("@/lib/types").MediaState | undefined;
try {
status = await getMediaStatus();
} catch {
/* client hooks surface errors */
}
return <MediaView initialStatus={status} />;
}
@@ -1,236 +0,0 @@
"use client";
import { Disc, Play, Repeat, SkipForward, Square } from "lucide-react";
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { Equalizer } from "@/components/charts";
import { Button, GlassPanel, Input, toast } from "@/components/primitives";
import {
ErrorState,
PageTransition,
SectionHeader,
SkeletonHero,
SkeletonPanel,
} from "@/components/shared";
import {
useMediaLoop,
useMediaQueue,
useMediaSkip,
useMediaState,
useMediaStop,
useMediaWsSync,
} from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import { formatDuration } from "@/lib/format";
import type { MediaState } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
const ws = useWebSocket();
const {
data: media,
isLoading,
error,
mutate,
} = useMediaState(initialStatus);
const queue = useMediaQueue();
const skip = useMediaSkip();
const stop = useMediaStop();
const loop = useMediaLoop();
useMediaWsSync(ws);
const ambient = useAmbient();
const [url, setUrl] = useState("");
const playing = media?.playing ?? false;
const current = media?.current ?? null;
const queueList = media?.queue ?? [];
const mediaRef = useStaggerReveal<HTMLDivElement>(".media-tile", {
stagger: 0.04,
y: 8,
dependencies: [playing, queueList.length],
});
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
useEffect(() => {
ambient.set(
tone,
playing ? 0.45 : 0.2,
playing ? "playing stream" : "media ready",
);
}, [tone, playing, ambient]);
const onPlay = async () => {
const u = url.trim();
if (!u) {
toast({ title: "Enter media URL", tone: "vermilion" });
return;
}
try {
await queue.mutateAsync({ url: u, mode: "music" });
setUrl("");
toast({ title: "Track queued", tone: "signal" });
} catch (e) {
toast({
title: "Queue failed",
description: String(e),
tone: "vermilion",
});
}
};
if (error && !media)
return <ErrorState error={error} onRetry={() => void mutate()} />;
if (!media && isLoading)
return (
<div className="space-y-4">
<SkeletonHero />
<SkeletonPanel rows={3} />
</div>
);
const mockLevels = playing
? [0.4, 0.7, 0.9, 0.6, 0.8, 0.5, 0.9, 0.7, 0.4, 0.8, 0.6, 0.9]
: [0.08, 0.08, 0.08, 0.08, 0.08, 0.08];
return (
<PageTransition>
<div ref={mediaRef} className="space-y-4">
{/* Precision Sub-Header Bar */}
<div className="media-tile flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex items-center gap-2.5">
<span
className={`h-2 w-2 rounded-full ${
playing
? "bg-signal shadow-[0_0_8px_var(--color-signal-glow)]"
: "bg-ink-muted"
}`}
/>
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
Audio Engine · Media Gateway
</h1>
</div>
<div className="flex items-center gap-2 font-mono text-[11px]">
<span className="text-ink-muted">ENGINE:</span>
<span
className={`rounded px-1.5 py-0.5 font-medium border ${
playing
? "border-signal/30 bg-signal/10 text-signal"
: "border-hairline bg-surface-2 text-ink-muted"
}`}
>
{playing ? "STREAMING" : "IDLE"}
</span>
</div>
</div>
{/* Media Playback Deck */}
<div className="media-tile">
<GlassPanel className="p-5">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3.5">
<div className="flex size-11 items-center justify-center rounded-[8px] border border-hairline bg-surface-2 text-signal">
<Disc
className={`size-6 ${playing ? "animate-spin-disc text-signal" : "text-ink-muted"}`}
/>
</div>
<div>
<div className="text-sm font-semibold tracking-tight text-ink">
{current?.title ?? "No Active Media Stream"}
</div>
<div className="font-mono text-[11px] text-ink-muted">
{current
? `${formatDuration(current.durationMs ?? 0)} · High Fidelity Stream`
: "Queue a track via URL below"}
</div>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => loop.mutateAsync()}
className={media?.loop ? "border-signal text-signal" : ""}
>
<Repeat className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => skip.mutateAsync()}
>
<SkipForward className="size-3.5" />
</Button>
<Button
variant="danger"
size="sm"
onClick={() => stop.mutateAsync()}
>
<Square className="size-3.5" />
</Button>
</div>
</div>
{/* Quick URL Input */}
<div className="mt-4 flex gap-2 border-t border-hairline pt-4">
<Input
placeholder="Paste YouTube, SoundCloud, or Direct Stream URL..."
value={url}
onChange={(e) => setUrl(e.target.value)}
className="flex-1 text-xs"
/>
<Button variant="primary" size="md" onClick={onPlay}>
<Play className="size-3.5" />
Queue Track
</Button>
</div>
</GlassPanel>
</div>
{/* Spectrum & Queue Grid */}
<div className="grid gap-3 lg:grid-cols-3">
<GlassPanel className="media-tile lg:col-span-2">
<SectionHeader
eyebrow="Visualizer"
title="Realtime Frequency Array"
/>
<div className="my-6 flex items-center justify-center">
<Equalizer bars={mockLevels} />
</div>
</GlassPanel>
<GlassPanel className="media-tile">
<SectionHeader
eyebrow="Playlist"
title={`Queue (${queueList.length})`}
/>
<div className="mt-3 space-y-2">
{queueList.length === 0 ? (
<div className="py-8 text-center font-mono text-xs text-ink-muted">
QUEUE EMPTY
</div>
) : (
queueList.map((item, idx) => (
<div
key={item.id ?? idx}
className="flex items-center justify-between rounded-[6px] border border-hairline bg-surface-2 p-2.5 text-xs text-ink-soft"
>
<span className="truncate pr-2 font-medium">
{item.title}
</span>
<span className="font-mono text-[10px] text-ink-muted shrink-0">
{formatDuration(item.durationMs ?? 0)}
</span>
</div>
))
)}
</div>
</GlassPanel>
</div>
</div>
</PageTransition>
);
}
@@ -263,7 +263,6 @@ export function MessagesView({
{/* Filter and Mode Bar */}
<GlassPanel className="flex flex-wrap items-center gap-3 p-3">
<GuildChannelPicker
mode="text"
guildsInitial={initialGuilds}
guildId={guildId}
channelId={channelId}
@@ -1,15 +0,0 @@
import { getRecordings } from "@/lib/api/server";
import type { PaginatedRecordings } from "@/lib/types";
import { RecordingsView } from "./view";
export const dynamic = "force-dynamic";
export default async function RecordingsPage() {
let recordings: PaginatedRecordings | undefined;
try {
recordings = await getRecordings(50);
} catch {
/* client hooks surface errors */
}
return <RecordingsView initialPage={recordings} />;
}
@@ -1,989 +0,0 @@
"use client";
import {
Download,
FileAudio,
Files,
Hash,
Headphones,
Loader2,
MessagesSquare,
Mic,
Pause,
Play,
Search,
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,
Input,
Select,
type SelectOption,
Skeleton,
toast,
} from "@/components/primitives";
import { EmptyState, ErrorState, SectionHeader } from "@/components/shared";
import {
NowPlayingChip,
RecordingAudioPlayer,
} from "@/components/voice/recording-audio-player";
import {
type RecordingsFilter,
useDeleteRecording,
useLoadMoreRecordings,
useRecordings,
useRecordingsSummary,
useRecordingsWsSync,
} from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import {
audioBufferToWav,
concatAudioBuffers,
decodeAudio,
downloadBlob,
} from "@/lib/audio/wav";
import { formatBytes, formatDuration, formatRelativeTime } from "@/lib/format";
import type {
PaginatedRecordings,
SpeakerSummary,
VoiceRecording,
} from "@/lib/types";
import { isVideoRecording } from "@/lib/types/recording";
import { useWebSocket } from "@/lib/ws/context";
const ALL = "__all__";
type Tab = "deck" | "leaderboard";
export function RecordingsView({
initialPage,
}: {
initialPage?: PaginatedRecordings;
}) {
const ws = useWebSocket();
const [tab, setTab] = useState<Tab>("deck");
const [q, setQ] = useState("");
const [channelId, setChannelId] = useState(ALL);
const [userId, setUserId] = useState(ALL);
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const activeFilter: RecordingsFilter = useMemo(() => {
const f: RecordingsFilter = {};
if (channelId !== ALL) f.channelId = channelId;
if (userId !== ALL) f.userId = userId;
const sq = q.trim();
if (sq) f.q = sq;
if (startDate) f.startDate = new Date(`${startDate}T00:00:00`).getTime();
if (endDate) f.endDate = new Date(`${endDate}T23:59:59`).getTime();
return f;
}, [channelId, userId, q, startDate, endDate]);
const {
data: items,
isLoading,
error,
nextCursor,
hasMore,
mutate,
} = useRecordings(initialPage, activeFilter);
const loadMore = useLoadMoreRecordings(activeFilter);
const del = useDeleteRecording();
const summary = useRecordingsSummary();
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);
const [exportingSessions, setExportingSessions] = useState<Set<string>>(
new Set(),
);
// Maximum older pages to prevent infinite runaway memory usage
const MAX_OLDER_PAGES = 10;
const [loadedPages, setLoadedPages] = useState(0);
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const deckRef = useStaggerReveal<HTMLDivElement>(".recording-deck-card", {
stagger: 0.04,
y: 10,
dependencies: [items?.length === 0],
});
useEffect(() => {
ambient.set("signal", 0.3, "recordings");
}, [ambient]);
// Distinct speakers/channels across the current (filtered) list.
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);
}
return [
{ value: ALL, label: "All speakers" },
...[...map.entries()].map(([value, label]) => ({ value, label })),
];
}, [items]);
const channelOptions = useMemo<SelectOption[]>(() => {
const map = new Map<string, string>();
for (const r of items ?? []) {
if (r.channel_id && !map.has(r.channel_id))
map.set(r.channel_id, r.channel_name ?? r.channel_id);
}
return [
{ value: ALL, label: "All channels" },
...[...map.entries()].map(([value, label]) => ({ value, label })),
];
}, [items]);
const sessions = useMemo(() => groupSessions(items ?? []), [items]);
const loadOlder = useCallback(async () => {
if (
!hasMore ||
!nextCursor ||
loadMore.isPending ||
loadedPages >= MAX_OLDER_PAGES
)
return;
try {
await loadMore.mutateAsync({ cursor: nextCursor });
setLoadedPages((n) => n + 1);
} catch {
// client error handling in hook/action
}
}, [hasMore, nextCursor, loadMore, loadedPages]);
// Infinite scroll trigger via IntersectionObserver on sentinel at the bottom of the list
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
void loadOlder();
}
},
{ root: scrollRef.current, rootMargin: "200px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [loadOlder]);
const onDelete = async (id: string) => {
try {
await del.mutateAsync(id);
toast({ title: "Recording deleted", tone: "signal" });
} catch (e) {
toast({
title: "Delete failed",
description: String(e),
tone: "vermilion",
});
}
};
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 =
userId !== ALL
? (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);
}
};
const onExportSession = async (session: Session) => {
if (exportingSessions.has(session.key)) return;
const target = session.clips.filter(
(r): r is VoiceRecording & { download_url: string } =>
Boolean(r.download_url),
);
if (target.length === 0) return;
setExportingSessions((prev) => new Set(prev).add(session.key));
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 safeChannel = (session.channelName ?? "session").replace(
/[^\w.-]+/g,
"_",
);
downloadBlob(wav, `gmw-session-${safeChannel}-${target.length}clips.wav`);
toast({
title: `Session exported: ${target.length} clips as one WAV`,
tone: "signal",
});
} catch (e) {
toast({
title: "Session export failed",
description: String(e),
tone: "vermilion",
});
} finally {
setExportingSessions((prev) => {
const next = new Set(prev);
next.delete(session.key);
return next;
});
}
};
if (error && !items)
return <ErrorState error={error} onRetry={() => void mutate()} />;
if (!items && isLoading)
return (
<GlassPanel>
<div className="mb-3 flex items-center justify-between">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-12" />
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<GlassCard key={i} className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Skeleton className="size-9 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-2.5 w-32" />
</div>
</div>
<Skeleton className="h-9 w-full" />
</GlassCard>
))}
</div>
</GlassPanel>
);
const totalRecordings = (items ?? []).length;
const exportableCount = (items ?? []).filter((r) => r.download_url).length;
return (
<div className="space-y-4">
{/* Tactical HUD Header Bar */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex items-center gap-2.5">
<span className="h-2 w-2 rounded-full bg-signal glow-pulse" />
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
Tape Deck · Captured Audio Archive
</h1>
</div>
<div className="flex items-center gap-1.5">
<TabButton active={tab === "deck"} onClick={() => setTab("deck")}>
<MessagesSquare className="size-3.5" /> DECK
</TabButton>
<TabButton
active={tab === "leaderboard"}
onClick={() => setTab("leaderboard")}
>
<Users className="size-3.5" /> LEADERBOARD
</TabButton>
</div>
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
<span>STATUS:</span>
<span
className="glitch-text rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30"
data-text={`${totalRecordings} CLIPS_LOADED`}
>
{totalRecordings} CLIPS_LOADED
</span>
</div>
</div>
{tab === "leaderboard" ? (
<Leaderboard
summary={summary.data}
isLoading={summary.isLoading}
onShowSpeaker={(uid) => {
setUserId(uid);
setTab("deck");
setLoadedPages(0);
}}
/>
) : (
<>
{/* Filter + Search + Export toolbar */}
<div className="flex flex-wrap items-center gap-2.5">
<div className="relative min-w-0 flex-1 basis-56">
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-ink-faint" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search transcriptions / speakers…"
className="pl-8"
/>
</div>
<Select
value={userId}
onChange={(v) => {
setUserId(v);
setLoadedPages(0);
}}
options={userOptions}
placeholder="Speaker"
size="sm"
className="w-40"
/>
<Select
value={channelId}
onChange={(v) => {
setChannelId(v);
setLoadedPages(0);
}}
options={channelOptions}
placeholder="Channel"
size="sm"
className="w-40"
/>
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
aria-label="From date"
className="w-36"
/>
<Input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
aria-label="To date"
className="w-36"
/>
<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>
{(Object.keys(activeFilter).length > 0 ||
channelId !== ALL ||
userId !== ALL) && (
<Button
variant="subtle"
size="sm"
onClick={() => {
setQ("");
setChannelId(ALL);
setUserId(ALL);
setStartDate("");
setEndDate("");
setLoadedPages(0);
}}
>
RESET
</Button>
)}
</div>
<GlassPanel>
<SectionHeader
eyebrow="acoustic buffer"
title="Voice Capture Tape Deck"
action={
<span className="mono text-xs text-[#8a8f98]">
{totalRecordings} clips loaded{" "}
{hasMore ? "· more available" : ""}
</span>
}
/>
{totalRecordings === 0 ? (
<EmptyState
icon={<Headphones className="size-7" />}
title={
Object.keys(activeFilter).length > 0
? "No recordings match these filters"
: "Tape deck is empty"
}
description={
Object.keys(activeFilter).length > 0
? "Try clearing the search or filters, or pick a different speaker/channel."
: "Voice transmissions captured in connected channels will be archived here."
}
/>
) : (
<div
ref={scrollRef}
className="mt-4 max-h-[calc(100vh-260px)] overflow-y-auto pr-1"
>
<div
ref={deckRef}
className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
>
{sessions.flatMap((session) => {
const sessionPlayable = session.clips.some((c) =>
Boolean(c.download_url),
);
const sessionExporting = exportingSessions.has(session.key);
const header = (
<SessionHeader
key={`session-${session.key}`}
session={session}
playable={sessionPlayable}
exporting={sessionExporting}
onExport={() => void onExportSession(session)}
/>
);
const cards = session.clips.map((r) => {
const up = uploadStatus(r);
const isPlaying = playingId === r.id;
const isExporting = exportingIds.has(r.id);
return (
<div
key={r.id}
className={`recording-deck-card hud-card flex flex-col justify-between p-4 transition-all duration-200 ${
isPlaying
? "border-signal/50 bg-signal/10 shadow-[0_0_24px_-10px_var(--color-signal-glow)]"
: ""
}`}
>
<div>
{/* Header info */}
<div className="flex items-center gap-3 border-b border-hairline pb-3">
<Avatar
src={r.avatar_url}
name={r.username}
size={36}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-ink">
{r.username}
</div>
<div className="flex items-center gap-1.5 font-mono text-[10px] text-ink-faint">
<Hash className="size-2.5 text-signal" />
<span className="truncate">
{r.channel_name ?? "voice"}
</span>
<span>·</span>
<span suppressHydrationWarning>
{formatRelativeTime(r.created_at)}
</span>
</div>
</div>
{isPlaying && <NowPlayingChip />}
{up && !isPlaying && (
<Badge
tone={up.tone}
className="font-mono text-[9px]"
>
{up.label}
</Badge>
)}
</div>
{/* Transcription snippet */}
<div className="py-2">
<TranscriptionSnippet text={r.transcription} />
</div>
{/* Audio Player Scrub */}
<div className="my-1">
{r.download_url ? (
<RecordingAudioPlayer
src={r.download_url}
label={`${
isVideoRecording(r) ? "Video" : "Voice"
} recording by ${r.username}`}
video={isVideoRecording(r)}
onPlayStateChange={(active) =>
setPlayingId((prev) => {
if (active) return r.id;
return prev === r.id ? null : prev;
})
}
/>
) : (
<div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 px-3 py-2 font-mono text-[11px] text-ink-muted">
<Loader2 className="size-3.5 animate-spin text-signal" />
{r.upload_status === "pending"
? "UPLOAD_PENDING..."
: r.upload_error
? r.upload_error
: "SYNTHESIZING_PCM..."}
</div>
)}
</div>
</div>
{/* Actions & File Stats */}
<div className="flex items-center justify-between border-t border-hairline pt-2.5">
<span className="font-mono text-[10px] text-ink-faint">
SIZE: {formatBytes(r.size_bytes)}
</span>
<div className="flex items-center gap-2">
{r.download_url && (
<>
<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"
onClick={() => onDelete(r.id)}
disabled={del.isPending}
className="inline-flex items-center gap-1 rounded-md border border-vermilion/30 bg-vermilion/10 px-2 py-1 font-mono text-[10px] text-vermilion transition-colors hover:bg-vermilion/20"
>
<Trash2 className="size-3" /> PURGE
</button>
</div>
</div>
</div>
);
});
return [header, ...cards];
})}
</div>
{/* Infinite scroll sentinel & status footer */}
<div ref={sentinelRef} className="py-4 text-center">
{loadMore.isPending ? (
<span className="flex items-center justify-center gap-2 font-mono text-xs text-ink-muted">
<Loader2 className="size-4 animate-spin text-signal" />
LOADING EARLIER RECORDINGS...
<span className="typing-dots">
<span />
<span />
<span />
</span>
</span>
) : hasMore && loadedPages < MAX_OLDER_PAGES ? (
<button
type="button"
onClick={loadOlder}
className="rounded-md border border-hairline bg-surface-2 px-3 py-1.5 font-mono text-xs text-ink-muted transition-colors hover:bg-surface hover:text-ink"
>
↓ LOAD MORE RECORDINGS
</button>
) : (
<span className="font-mono text-[10px] text-ink-faint">
{loadedPages >= MAX_OLDER_PAGES
? `CAPPED AT ${MAX_OLDER_PAGES} PAGES`
: "ARCHIVE END REACHED"}
</span>
)}
</div>
</div>
)}
</GlassPanel>
</>
)}
</div>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 font-mono text-[10px] transition-colors ${
active
? "border-signal/40 bg-signal/15 text-signal"
: "border-hairline bg-surface-2 text-ink-soft hover:bg-surface hover:text-ink"
}`}
>
{children}
</button>
);
}
function TranscriptionSnippet({ text }: { text?: string | null }) {
const [open, setOpen] = useState(false);
if (!text) {
return (
<div className="h-10 rounded-[6px] border border-dashed border-hairline bg-surface-2/40 px-3 py-2 font-mono text-[10px] text-ink-faint">
no transcription
</div>
);
}
const collapsed = !open && text.length > 160;
return (
<div className="rounded-[6px] border border-hairline bg-surface-2 px-3 py-2">
<p
className={`font-mono text-[11px] leading-relaxed text-ink-soft ${
collapsed ? "line-clamp-3" : ""
}`}
>
{text}
</p>
{collapsed && (
<button
type="button"
onClick={() => setOpen(true)}
className="mt-1 font-mono text-[10px] text-signal hover:underline"
>
READ FULL TRANSCRIPT
</button>
)}
</div>
);
}
function Leaderboard({
summary,
isLoading,
onShowSpeaker,
}: {
summary: SpeakerSummary[] | undefined;
isLoading: boolean;
onShowSpeaker: (userId: string, username: string) => void;
}) {
if (isLoading && !summary)
return (
<GlassPanel>
<Skeleton className="h-4 w-40" />
<div className="mt-3 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</GlassPanel>
);
if (!summary || summary.length === 0)
return (
<GlassPanel>
<EmptyState
icon={<Mic className="size-7" />}
title="No speakers yet"
description="Leaderboard populates as voice is captured and transcribed."
/>
</GlassPanel>
);
const maxClips = Math.max(...summary.map((s) => s.clips), 1);
return (
<GlassPanel>
<SectionHeader
eyebrow="speech analytics"
title="Speaker Leaderboard"
action={
<span className="mono text-xs text-[#8a8f98]">
{summary.length} speakers
</span>
}
/>
<div className="mt-4 space-y-1.5">
{summary.map((s, idx) => (
<button
key={s.user_id}
type="button"
onClick={() => onShowSpeaker(s.user_id, s.username)}
className="group flex w-full items-center gap-3 rounded-lg border border-hairline bg-surface-2/50 px-3 py-2.5 text-left transition-colors hover:border-signal/40 hover:bg-surface"
>
<span className="w-6 shrink-0 text-center font-mono text-xs text-ink-faint">
{idx + 1}
</span>
<Avatar src={s.avatar_url} name={s.username} size={32} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-ink">
{s.username}
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-surface">
<div
className="h-full rounded-full bg-signal"
style={{ width: `${(s.clips / maxClips) * 100}%` }}
/>
</div>
</div>
<div className="shrink-0 text-right font-mono text-[10px] text-ink-soft">
<div className="flex items-center justify-end gap-1">
<Mic className="size-3 text-signal" /> {s.clips}
</div>
<div>{formatDuration(s.est_duration_s * 1000)}</div>
<div className="text-ink-faint">
{s.transcribed > 0 ? `${s.words} words` : "no transcript"}
</div>
<div className="text-ink-faint" suppressHydrationWarning>
{s.last_at ? `active ${formatRelativeTime(s.last_at)}` : "—"}
</div>
</div>
</button>
))}
</div>
</GlassPanel>
);
}
function uploadStatus(
r: VoiceRecording,
): { tone: "neutral" | "amber" | "vermilion"; label: string } | null {
if (r.download_url) return null;
if (r.upload_status === "error" || r.upload_error)
return { tone: "vermilion", label: "failed" };
if (r.upload_status === "pending") return { tone: "amber", label: "pending" };
return { tone: "amber", label: "processing" };
}
// ── Session grouping ──────────────────────────────────────────────────────
// Clips become one "meeting session" when they share a channel and the gap
// between consecutive clips is within SESSION_GAP_MS. items arrive newest-first,
// so sessions are produced most-recent-first and each keeps its clips in that
// order (newest → oldest).
const SESSION_GAP_MS = 120_000;
interface Session {
key: string;
channelId: string;
channelName: string;
startTs: number;
endTs: number;
clips: VoiceRecording[];
}
function groupSessions(items: VoiceRecording[]): Session[] {
const sessions: Session[] = [];
for (const r of items) {
const last = sessions[sessions.length - 1];
if (
last &&
last.channelId === (r.channel_id ?? "") &&
last.startTs - r.created_at <= SESSION_GAP_MS
) {
last.clips.push(r);
last.startTs = r.created_at;
} else {
sessions.push({
key: r.id,
channelId: r.channel_id ?? "",
channelName: r.channel_name ?? "voice",
startTs: r.created_at,
endTs: r.created_at,
clips: [r],
});
}
}
return sessions;
}
function sessionEstDurationSec(session: Session): number {
const bytes = session.clips.reduce((acc, c) => acc + c.size_bytes, 0);
return Math.round((bytes * 8) / 128000);
}
function sessionTimestamp(session: Session): string {
const d = new Date(session.endTs);
const s = new Date(session.startTs);
const sameDay = d.toDateString() === s.toDateString();
const fmt = (x: Date) =>
`${x.getMonth() + 1}/${x.getDate()} ${String(x.getHours()).padStart(2, "0")}:${String(
x.getMinutes(),
).padStart(2, "0")}`;
return sameDay ? fmt(d) : `${fmt(d)} – ${fmt(s)}`;
}
/** Sequential playback of a session's clips via one <audio> element. */
function SessionPlayer({
clips,
}: {
clips: Array<VoiceRecording & { download_url: string }>;
}) {
const [idx, setIdx] = useState(0);
const [playing, setPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
const audio = new Audio();
audio.preload = "metadata";
audioRef.current = audio;
const onEnded = () => {
setIdx((i) => {
const next = i + 1;
if (next < clips.length) {
audio.src = clips[next].download_url;
void audio.play().catch(() => setPlaying(false));
return next;
}
setPlaying(false);
return 0;
});
};
audio.addEventListener("ended", onEnded);
return () => {
audio.removeEventListener("ended", onEnded);
audio.pause();
audio.src = "";
};
}, [clips]);
const toggle = () => {
const audio = audioRef.current;
if (!audio) return;
if (playing) {
audio.pause();
setPlaying(false);
} else {
if (idx >= clips.length || audio.src === "")
audio.src = clips[0].download_url;
void audio.play().catch(() => setPlaying(false));
setPlaying(true);
}
};
return (
<button
type="button"
onClick={toggle}
title={
playing ? "Pause session playback" : "Play all clips in this session"
}
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"
>
{playing ? <Pause className="size-3" /> : <Play className="size-3" />}
{playing ? `PAUSE ${idx + 1}/${clips.length}` : "PLAY SESSION"}
</button>
);
}
function SessionHeader({
session,
playable,
exporting,
onExport,
}: {
session: Session;
playable: boolean;
exporting: boolean;
onExport: () => void;
}) {
return (
<div className="col-span-full -mx-1 mb-1 mt-2 flex items-center gap-3 rounded-lg border border-hairline bg-surface px-3 py-2 first:mt-0">
<Hash className="size-3.5 shrink-0 text-signal" />
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-[11px] font-semibold uppercase tracking-wide text-ink">
{session.channelName}
</div>
<div className="font-mono text-[10px] text-ink-faint">
{sessionTimestamp(session)} · {session.clips.length} clips ·{" "}
{formatDuration(sessionEstDurationSec(session) * 1000)}
</div>
</div>
{playable && (
<SessionPlayer
clips={session.clips.filter(
(c): c is VoiceRecording & { download_url: string } =>
Boolean(c.download_url),
)}
/>
)}
<button
type="button"
onClick={onExport}
disabled={exporting || !playable}
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"
>
{exporting ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Files className="size-3" />
)}
{exporting ? "WAV..." : "EXPORT SESSION"}
</button>
</div>
);
}
@@ -1,15 +0,0 @@
import { getGuilds, getVoiceStatus } from "@/lib/api/server";
import { VoiceView } from "./view";
export const dynamic = "force-dynamic";
export default async function VoicePage() {
let status: import("@/lib/types").VoiceStatus | undefined;
let guilds: import("@/lib/types").Guild[] | undefined;
try {
[status, guilds] = await Promise.all([getVoiceStatus(), getGuilds()]);
} catch {
/* client hooks surface errors */
}
return <VoiceView initialStatus={status} initialGuilds={guilds} />;
}
@@ -1,490 +0,0 @@
"use client";
import {
Cable,
Mic,
PhoneOff,
Radio,
ShieldCheck,
ShieldOff,
Volume2,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { Button, GlassPanel, toast } from "@/components/primitives";
import {
ErrorState,
PageTransition,
SectionHeader,
SkeletonPanel,
} from "@/components/shared";
import { GuildChannelPicker } from "@/components/shared/guild-picker";
import { VoiceStage } from "@/components/voice/voice-stage";
import {
useMicTransmit,
useSpeakers,
useVoiceConnect,
useVoiceDisconnect,
useVoiceListen,
useVoiceStatus,
} from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import type { Guild, VoiceStatus } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
export function VoiceView({
initialStatus,
initialGuilds,
}: {
initialStatus?: VoiceStatus;
initialGuilds?: Guild[];
}) {
const ws = useWebSocket();
const {
data: status,
isLoading,
error,
mutate,
} = useVoiceStatus(initialStatus);
const connect = useVoiceConnect();
const disconnect = useVoiceDisconnect();
const mic = useMicTransmit(ws);
const listen = useVoiceListen(ws);
const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers);
const ambient = useAmbient();
const [guildId, setGuildId] = useState<string | null>(
initialStatus?.activeGuildId ?? initialGuilds?.[0]?.id ?? null,
);
const [channelId, setChannelId] = useState<string | null>(
initialStatus?.activeChannelId ?? null,
);
const [micVol, setMicVol] = useState(100);
const [listenVol, setListenVol] = useState(75);
const [micActive, setMicActive] = useState(false);
const [listenActive, setListenActive] = useState(false);
const toggleMic = async () => {
const next = !micActive;
try {
await mic.mutateAsync(next);
setMicActive(next);
toast({
title: next ? "Mic activated" : "Mic deactivated",
tone: next ? "signal" : "neutral",
});
} catch (e) {
// MicAccessError from mic-transmit.ts has specific reasons
const msg = e instanceof Error ? e.message : String(e);
const isPermDenied = msg.includes("denied") || msg.includes("Permission");
const isNoMic = msg.includes("No microphone");
toast({
title: isPermDenied
? "Mic permission denied"
: isNoMic
? "No microphone found"
: "Mic toggle failed",
description: msg,
tone: "vermilion",
});
}
};
const toggleListen = () => {
const next = !listenActive;
listen.toggle(next);
setListenActive(next);
toast({
title: next ? "Monitor activated" : "Monitor deactivated",
tone: next ? "signal" : "neutral",
});
};
const containerRef = useStaggerReveal<HTMLDivElement>(".voice-tile", {
stagger: 0.04,
y: 8,
dependencies: [status],
});
useEffect(() => {
const unsub = subscribe(ws);
return unsub;
}, [subscribe, ws]);
useEffect(() => {
if (status?.connected) ambient.set("signal", 0.45, "voice active");
else ambient.set("vermilion", 0.25, "voice disconnected");
}, [status?.connected, ambient]);
if (error && !status)
return <ErrorState error={error} onRetry={() => void mutate()} />;
if (!status && isLoading)
return (
<div className="space-y-4">
<SkeletonPanel rows={3} />
<SkeletonPanel rows={5} />
</div>
);
const connected = status?.connected ?? false;
const onConnect = async () => {
if (!guildId || !channelId) {
toast({ title: "Select target channel", tone: "vermilion" });
return;
}
try {
await connect.mutateAsync({ guildId, channelId });
toast({ title: "Connected to voice channel", tone: "signal" });
} catch (e) {
toast({
title: "Connection failed",
description: String(e),
tone: "vermilion",
});
}
};
const onDisconnect = async () => {
try {
await disconnect.mutateAsync();
toast({ title: "Disconnected from voice", tone: "neutral" });
} catch (e) {
toast({
title: "Disconnect failed",
description: String(e),
tone: "vermilion",
});
}
};
return (
<PageTransition>
<div ref={containerRef} className="space-y-4">
{/* Precision Sub-Header Bar */}
<div className="voice-tile flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex items-center gap-2.5">
<span
className={`h-2 w-2 rounded-full ${
connected
? "bg-success shadow-[0_0_8px_var(--color-status-success)]"
: "bg-ink-muted"
}`}
/>
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
Voice Spectrum & Stage Controls
</h1>
</div>
<div className="flex items-center gap-2 font-mono text-[11px]">
<span className="text-ink-muted">STAGE:</span>
<span
className={`rounded px-1.5 py-0.5 font-medium border ${
connected
? "border-success/30 bg-success/10 text-success"
: "border-hairline bg-surface-2 text-ink-muted"
}`}
>
{connected ? "LIVE_BRIDGE" : "OFFLINE"}
</span>
</div>
</div>
{/* Channel Router Stage */}
<div className="voice-tile">
<GlassPanel className="p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex-1 min-w-[240px]">
<GuildChannelPicker
guildId={guildId}
channelId={channelId}
guildsInitial={initialGuilds}
onChange={(g, c) => {
setGuildId(g);
setChannelId(c);
}}
mode="voice"
/>
</div>
<div className="flex items-center gap-2">
{connected ? (
<Button
variant="danger"
size="md"
onClick={onDisconnect}
className="gap-1.5"
>
<PhoneOff className="size-3.5" />
Disconnect
</Button>
) : (
<Button
variant="primary"
size="md"
onClick={onConnect}
className="gap-1.5"
>
<Radio className="size-3.5" />
Connect Bridge
</Button>
)}
</div>
</div>
</GlassPanel>
</div>
{/* Connected Bridges — multi-guild voice bridge roster */}
<GlassPanel className="voice-tile">
<SectionHeader
eyebrow="Bridges"
title="Connected Voice Bridges"
action={
<span className="mono text-xs text-[#8a8f98]">
{status?.connections?.length ?? (connected ? 1 : 0)} ACTIVE
</span>
}
/>
<div className="mt-3">
{status?.connections && status.connections.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2">
{status.connections.map((conn) => {
const isActive =
conn.guildId === status.activeGuildId &&
conn.channelId === status.activeChannelId;
return (
<div
key={`${conn.guildId}-${conn.channelId}`}
className={`flex items-center gap-2.5 rounded-[8px] border p-2.5 ${
isActive
? "border-success/40 bg-success/5"
: "border-hairline bg-surface-2"
}`}
>
<span
className={`flex size-8 shrink-0 items-center justify-center rounded-full border ${
isActive
? "border-success/40 bg-success/10 text-success"
: "border-hairline bg-surface text-ink-muted"
}`}
>
<Cable className="size-3.5" />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-ink">
{conn.channelName || "#" + conn.channelId.slice(0, 8)}
</div>
<div className="mt-0.5 flex items-center gap-1.5 font-mono text-[9px] text-ink-faint">
<span className="truncate">
G:{conn.guildId.slice(0, 8)} · C:
{conn.channelId.slice(0, 8)}
</span>
{isActive && (
<span className="rounded bg-success/15 px-1 text-success">
ACTIVE
</span>
)}
</div>
</div>
<span
className="shrink-0 font-mono text-[9px] text-ink-faint"
suppressHydrationWarning
>
{new Date(conn.connectedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
);
})}
</div>
) : connected ? (
<div className="flex items-center gap-2.5 rounded-[8px] border border-success/30 bg-success/5 p-2.5">
<span className="flex size-8 items-center justify-center rounded-full border border-success/40 bg-success/10 text-success">
<Cable className="size-3.5" />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-ink">
{status?.activeChannelName || "Voice channel"}
</div>
<div className="mt-0.5 font-mono text-[9px] text-ink-faint">
Active bridge · G:
{(status?.activeGuildId ?? guildId ?? "").slice(0, 8)}
</div>
</div>
<span className="rounded bg-success/15 px-1 font-mono text-[9px] text-success">
ACTIVE
</span>
</div>
) : (
<div className="rounded-[8px] border border-dashed border-hairline p-4 text-center">
<span className="font-mono text-[10px] text-ink-faint">
No active voice bridges
</span>
</div>
)}
</div>
</GlassPanel>
{/* Voice Stage Grid */}
<div className="grid gap-3 lg:grid-cols-3">
<GlassPanel className="voice-tile lg:col-span-2">
<SectionHeader
eyebrow="Participants"
title="Active Stage Members"
/>
<div className="mt-4 min-h-[160px]">
<VoiceStage speakers={speakers} />
</div>
</GlassPanel>
<GlassPanel className="voice-tile flex flex-col justify-between">
<div>
<SectionHeader eyebrow="Telemetry" title="Input / Output Mix" />
<div className="mt-4 space-y-4">
{/* Mic Toggle */}
<div>
<div className="flex items-center justify-between">
<button
type="button"
onClick={toggleMic}
className={`flex items-center gap-1.5 rounded-[8px] px-3 py-1.5 text-xs font-medium transition-all ${
micActive
? "bg-signal/15 text-signal border border-signal/40 glow-pulse"
: "bg-surface-2 text-ink-muted border border-hairline hover:border-signal/30 hover:text-ink"
}`}
>
<Mic className="size-3.5" />
{micActive ? "MIC LIVE" : "MIC OFF"}
</button>
<span className="font-mono text-[11px] text-ink-muted">
{micVol}%
</span>
</div>
<input
type="range"
min={0}
max={150}
value={micVol}
onChange={(e) => {
const v = Number(e.target.value);
setMicVol(v);
mic.setVolume(v / 100);
}}
className="mt-2 h-1.5 w-full appearance-none rounded-full bg-surface-2 accent-signal"
/>
{/* Live mic level meter */}
{micActive && (
<div className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-surface-2">
<div
className="h-full rounded-full bg-signal transition-all duration-100"
style={{
width: `${Math.min(100, mic.micLevel * 100)}%`,
}}
/>
</div>
)}
{/* Noise Suppression Toggle */}
<div className="mt-2.5 flex items-center gap-2">
<button
type="button"
onClick={() => {
const next = !mic.noiseSuppression;
mic.toggleNoiseSuppression(next);
toast({
title: next
? "Noise suppression ON"
: "Noise suppression OFF",
tone: next ? "signal" : "neutral",
});
}}
className={`flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium transition-all ${
mic.noiseSuppression
? "bg-success/15 text-success border border-success/30"
: "bg-surface-2 text-ink-faint border border-hairline hover:border-ink-muted/30"
}`}
>
{mic.noiseSuppression ? (
<ShieldCheck className="size-3" />
) : (
<ShieldOff className="size-3" />
)}
NS {mic.noiseSuppression ? "ON" : "OFF"}
</button>
{micActive && (
<span className="font-mono text-[9px] text-ink-faint">
{mic.noiseSuppression ? "noise gated" : "raw audio"}
</span>
)}
</div>
</div>
{/* Listen Toggle */}
<div>
<div className="flex items-center justify-between">
<button
type="button"
onClick={toggleListen}
className={`flex items-center gap-1.5 rounded-[8px] px-3 py-1.5 text-xs font-medium transition-all ${
listenActive
? "bg-success/15 text-success border border-success/40 glow-pulse"
: "bg-surface-2 text-ink-muted border border-hairline hover:border-success/30 hover:text-ink"
}`}
>
<Volume2 className="size-3.5" />
{listenActive ? "MONITOR LIVE" : "MONITOR OFF"}
</button>
<span className="font-mono text-[11px] text-ink-muted">
{listenVol}%
</span>
</div>
<input
type="range"
min={0}
max={150}
value={listenVol}
onChange={(e) => {
const v = Number(e.target.value);
setListenVol(v);
listen.setVolume(v / 100);
}}
className="mt-2 h-1.5 w-full appearance-none rounded-full bg-surface-2 accent-success"
/>
{/* Per-speaker level meters */}
{listenActive && listen.levels.size > 0 && (
<div className="mt-2 space-y-1">
{Array.from(listen.levels.entries()).map(
([hash, level]) => (
<div key={hash} className="flex items-center gap-2">
<span className="font-mono text-[9px] text-ink-faint w-8">
#{hash.toString(16).slice(-3)}
</span>
<div className="h-1 flex-1 overflow-hidden rounded-full bg-surface-2">
<div
className="h-full rounded-full bg-success transition-all duration-100"
style={{
width: `${Math.min(100, level * 100)}%`,
}}
/>
</div>
</div>
),
)}
</div>
)}
</div>
</div>
</div>
<div className="mt-6 border-t border-hairline pt-3">
<div className="font-mono text-[10px] text-ink-muted">
CODEC: OPUS 48KHZ · LOW_LATENCY
{mic.noiseSuppression ? " · NS_ACTIVE" : ""}
</div>
</div>
</GlassPanel>
</div>
</div>
</PageTransition>
);
}
@@ -1,163 +0,0 @@
"use client";
import { ListMusic, SkipForward, Square } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import {
useMediaLoop,
useMediaSkip,
useMediaState,
useMediaStop,
useMediaWsSync,
} from "@/hooks";
import { formatDuration } from "@/lib/format";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
/**
* Persistent now-playing bar, fixed above the mobile dock / bottom of the
* viewport. Hidden on the /media route (the full player lives there) and
* entirely when nothing is queued. Shares the SWR media-state cache with
* every other consumer, so state stays consistent across routes.
*/
export function MiniPlayer() {
const ws = useWebSocket();
const pathname = usePathname();
const { data: media } = useMediaState();
useMediaWsSync(ws);
const skip = useMediaSkip();
const stop = useMediaStop();
const loop = useMediaLoop();
const ambient = useAmbient();
const hidden = pathname === "/media";
const current = hidden ? null : (media?.current ?? null);
const playing = media?.playing ?? false;
const queueLen = (media?.queue ?? []).length;
// Keep the ambient tint in sync while the bar is visible on non-media routes.
useEffect(() => {
if (hidden || !current) return;
ambient.set(
playing ? "signal" : "amber",
playing ? 0.4 : 0.2,
"mini-player",
);
}, [hidden, current, playing, ambient]);
if (!current) return null;
return (
<div
className={cn(
"pointer-events-auto fixed inset-x-3 bottom-[calc(4.5rem+env(safe-area-inset-bottom))] z-40",
"md:inset-x-auto md:right-5 md:bottom-5 md:w-[22rem]",
"animate-fade-up",
)}
>
<div className="glass flex items-center gap-3 rounded-[14px] px-3 py-2.5 shadow-[0_12px_40px_-16px_oklch(0_0_0/0.7)]">
<Link
href="/media"
className="flex min-w-0 flex-1 items-center gap-3"
aria-label="Open full media player"
>
<span className="relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full border border-hairline bg-surface-2">
{current.thumbnailUrl ? (
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
<img
src={current.thumbnailUrl}
alt=""
className={cn(
"size-full object-cover",
playing && "animate-spin-disc",
)}
loading="lazy"
/>
) : (
<ListMusic
className={cn(
"size-4",
playing ? "text-signal" : "text-ink-faint",
)}
/>
)}
{playing && (
<span
aria-hidden
className="absolute -inset-1 rounded-full border border-signal/30 animate-pulse-ring"
/>
)}
</span>
<span className="min-w-0 flex-1">
<span className="eyebrow block !text-[0.55rem] leading-tight">
{playing ? (
<span className="inline-flex items-center gap-1.5">
<span aria-hidden className="flex h-2 items-end gap-[2px]">
{[0, 1].map((i) => (
<span
key={`eq-${i}`}
className="w-[3px] animate-eq rounded-full bg-signal"
style={{
animationDelay: `${i * 180}ms`,
height: "100%",
}}
/>
))}
</span>
now playing
</span>
) : (
"paused"
)}
</span>
<span className="block truncate text-sm text-ink">
{current.title}
</span>
{current.durationMs != null && (
<span className="mono block text-[0.6rem] text-ink-faint">
{formatDuration(current.durationMs)}
{queueLen > 0 && ` · ${queueLen} in queue`}
</span>
)}
</span>
</Link>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => skip.mutate()}
disabled={skip.isPending}
aria-label="Skip to next track"
className="flex size-8 items-center justify-center rounded-full text-ink-soft transition-colors hover:bg-surface hover:text-signal active:scale-95"
>
<SkipForward className="size-4" />
</button>
<button
type="button"
onClick={() => stop.mutate()}
disabled={stop.isPending}
aria-label="Stop playback"
className="flex size-8 items-center justify-center rounded-full text-ink-faint transition-colors hover:bg-vermilion/15 hover:text-vermilion active:scale-95"
>
<Square className="size-3.5" />
</button>
<button
type="button"
onClick={() => loop.mutate(!media?.loop)}
aria-pressed={!!media?.loop}
aria-label="Toggle loop"
className={`hidden size-8 items-center justify-center rounded-full text-xs transition-colors sm:flex ${
media?.loop
? "bg-signal/15 text-signal"
: "text-ink-faint hover:bg-surface hover:text-ink"
}`}
>
↻
</button>
</div>
</div>
</div>
);
}
@@ -2,27 +2,23 @@
import { useEffect, useState } from "react";
import { Select, type SelectOption } from "@/components/primitives";
import { useGuilds, useTextChannels, useVoiceChannels } from "@/hooks";
import { useGuilds, useTextChannels } from "@/hooks";
import type { Guild } from "@/lib/types";
export function GuildChannelPicker({
mode,
guildsInitial,
guildId,
channelId,
onChange,
}: {
mode: "voice" | "text";
guildsInitial?: Guild[];
guildId: string | null;
channelId: string | null;
onChange: (guildId: string, channelId: string | null) => void;
}) {
const { data: guilds } = useGuilds(guildsInitial);
// Call both hooks unconditionally (rules of hooks); select by mode.
const voiceChannels = useVoiceChannels(guildId ?? "");
const textChannels = useTextChannels(guildId ?? "");
const channels = mode === "voice" ? voiceChannels.data : textChannels.data;
const channels = textChannels.data;
const [g, setG] = useState(guildId);
const [c, setC] = useState(channelId);
@@ -61,7 +57,7 @@ export function GuildChannelPicker({
if (g) onChange(g, v);
}}
options={channelOpts}
placeholder={mode === "voice" ? "Voice channel" : "Text channel"}
placeholder="Text channel"
size="sm"
className="w-full sm:w-52"
/>
@@ -1,4 +1,3 @@
import { MiniPlayer } from "@/components/media/mini-player";
import { MobileNav } from "./mobile-nav";
import { NavRail } from "./nav-rail";
import { TopBar } from "./topbar";
@@ -10,8 +9,7 @@ import { TopBar } from "./topbar";
*
* < md the side rail collapses (hidden) and a bottom tab bar (MobileNav)
* takes over navigation; the content region gains bottom padding so the last
* panel never hides behind the dock. A persistent MiniPlayer floats at the
* bottom-right whenever a media track is loaded outside /media.
* panel never hides behind the dock.
*/
export function AppFrame({ children }: { children: React.ReactNode }) {
return (
@@ -24,7 +22,6 @@ export function AppFrame({ children }: { children: React.ReactNode }) {
</main>
</div>
<MobileNav />
<MiniPlayer />
</div>
);
}
@@ -1,352 +0,0 @@
"use client";
import { Loader2, Pause, Play, Signal, Volume2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
/**
* Single-playback registry: playing one clip pauses every other instance.
* Module-level so it survives across cards without a context provider.
*/
const activePlayers = new Set<() => void>();
function registerPlayer(pause: () => void): () => void {
activePlayers.add(pause);
return () => activePlayers.delete(pause);
}
function formatTime(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return "0:00";
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
/** Shared event wiring for a video element (kept small to avoid dup logic). */
function wireVideoEvents(
el: HTMLVideoElement,
onLoadedMeta: () => void,
onTime: () => void,
onEnd: () => void,
onPause: () => void,
onPlaying: () => void,
): void {
el.addEventListener("loadedmetadata", onLoadedMeta);
el.addEventListener("durationchange", onLoadedMeta);
el.addEventListener("timeupdate", onTime);
el.addEventListener("ended", onEnd);
el.addEventListener("pause", onPause);
el.addEventListener("playing", onPlaying);
}
/** Remove the listeners added by {@link wireVideoEvents}. */
function unwireVideoEvents(
el: HTMLVideoElement,
onLoadedMeta: () => void,
onTime: () => void,
onEnd: () => void,
onPause: () => void,
onPlaying: () => void,
): void {
el.removeEventListener("loadedmetadata", onLoadedMeta);
el.removeEventListener("durationchange", onLoadedMeta);
el.removeEventListener("timeupdate", onTime);
el.removeEventListener("ended", onEnd);
el.removeEventListener("pause", onPause);
el.removeEventListener("playing", onPlaying);
}
interface Props {
src: string;
label?: string;
/** True when the recording is video (camera/screen-share MP4). */
video?: boolean;
/** Lifted state: parent highlights the card that owns the active player. */
onPlayStateChange?: (playing: boolean) => void;
className?: string;
}
/**
* Custom recording player replacing native `<audio controls>`:
* play/pause with buffering spinner, click-to-seek progress bar, time label,
* animated equalizer bars while playing, and single-playback enforcement
* (starting one clip pauses all others).
*
* For `video` recordings it renders a native `<video controls>` instead —
* video needs the browser's own scrubbing/fullscreen UI — but still honors
* the single-playback registry and lifts play state to the parent card.
*/
export function RecordingAudioPlayer({
src,
label = "Voice recording",
video = false,
onPlayStateChange,
className,
}: Props) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const [playing, setPlaying] = useState(false);
const [buffering, setBuffering] = useState(false);
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
// Audio: hide an HTMLAudioElement and drive it with the custom scrubber.
useEffect(() => {
if (video) return;
const audio = new Audio();
audio.preload = "metadata";
audio.src = src;
audioRef.current = audio;
const onLoadedMeta = () => setDuration(audio.duration || 0);
const onTime = () => setCurrent(audio.currentTime);
const onEnd = () => {
setPlaying(false);
setBuffering(false);
setCurrent(0);
audio.currentTime = 0;
};
const onPause = () => {
setPlaying(false);
setBuffering(false);
};
const onPlaying = () => {
setPlaying(true);
setBuffering(false);
};
const onWaiting = () => setBuffering(true);
audio.addEventListener("loadedmetadata", onLoadedMeta);
audio.addEventListener("durationchange", onLoadedMeta);
audio.addEventListener("timeupdate", onTime);
audio.addEventListener("ended", onEnd);
audio.addEventListener("pause", onPause);
audio.addEventListener("playing", onPlaying);
audio.addEventListener("play", onWaiting);
audio.addEventListener("waiting", onWaiting);
// Single playback: while this player is active, pause any other that starts.
const pauseThis = () => audio.pause();
let unregister: (() => void) | null = null;
const onPlayEvt = () => {
for (const other of activePlayers) {
if (other !== pauseThis) other();
}
unregister?.();
unregister = registerPlayer(pauseThis);
};
audio.addEventListener("play", onPlayEvt);
return () => {
unregister?.();
audio.pause();
audio.removeEventListener("loadedmetadata", onLoadedMeta);
audio.removeEventListener("durationchange", onLoadedMeta);
audio.removeEventListener("timeupdate", onTime);
audio.removeEventListener("ended", onEnd);
audio.removeEventListener("pause", onPause);
audio.removeEventListener("playing", onPlaying);
audio.removeEventListener("play", onWaiting);
audio.removeEventListener("waiting", onWaiting);
audio.removeEventListener("play", onPlayEvt);
audio.src = "";
audioRef.current = null;
};
}, [src, video]);
// Video: wire the rendered <video controls> for single-playback + lifted state.
useEffect(() => {
if (!video) return;
const el = videoRef.current;
if (!el) return;
const onLoadedMeta = () => setDuration(el.duration || 0);
const onTime = () => setCurrent(el.currentTime);
const onEnd = () => {
setPlaying(false);
setCurrent(0);
};
const onPause = () => setPlaying(false);
const onPlaying = () => setPlaying(true);
wireVideoEvents(el, onLoadedMeta, onTime, onEnd, onPause, onPlaying);
// Single playback: pausing any other video/audio that starts.
const pauseThis = () => el.pause();
let unregister: (() => void) | null = null;
const onPlayEvt = () => {
for (const other of activePlayers) {
if (other !== pauseThis) other();
}
unregister?.();
unregister = registerPlayer(pauseThis);
};
el.addEventListener("play", onPlayEvt);
return () => {
unregister?.();
unwireVideoEvents(el, onLoadedMeta, onTime, onEnd, onPause, onPlaying);
el.removeEventListener("play", onPlayEvt);
};
}, [video]);
useEffect(() => {
onPlayStateChange?.(playing || buffering);
}, [playing, buffering, onPlayStateChange]);
const toggle = useCallback(() => {
const el = video ? videoRef.current : audioRef.current;
if (!el) return;
if (el.paused) {
setBuffering(true);
void el.play().catch(() => setBuffering(false));
} else {
el.pause();
}
}, [video]);
const seek = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const el = video ? videoRef.current : audioRef.current;
if (!el || !Number.isFinite(el.duration)) return;
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.min(
1,
Math.max(0, (e.clientX - rect.left) / rect.width),
);
el.currentTime = ratio * el.duration;
setCurrent(el.currentTime);
},
[video],
);
const pct = duration > 0 ? (current / duration) * 100 : 0;
if (video) {
return (
<div
className={cn(
"rounded-[8px] border bg-surface-2 p-1.5 transition-colors",
playing
? "border-signal/40 shadow-[0_0_24px_-10px_var(--color-signal-glow)]"
: "border-hairline",
className,
)}
role="group"
aria-label={label}
>
<video
ref={videoRef}
src={src}
controls
playsInline
preload="metadata"
className="aspect-video w-full rounded-[5px] bg-black object-contain"
/>
</div>
);
}
return (
<div
className={cn(
"rounded-[8px] border bg-surface-2 px-3 py-2.5 transition-colors",
playing || buffering
? "border-signal/40 shadow-[0_0_24px_-10px_var(--color-signal-glow)]"
: "border-hairline",
className,
)}
role="group"
aria-label={label}
>
<div className="flex items-center gap-3">
<button
type="button"
onClick={toggle}
aria-pressed={playing}
aria-label={playing ? "Pause" : "Play"}
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-full border transition-all active:scale-95",
playing || buffering
? "border-signal/50 bg-signal/15 text-signal"
: "border-hairline bg-surface text-ink-soft hover:border-signal/40 hover:text-ink",
)}
>
{buffering ? (
<Loader2 className="size-4 animate-spin" />
) : playing ? (
<Pause className="size-4" />
) : (
<Play className="size-4 translate-x-[1px]" />
)}
</button>
{/* seekable progress */}
<div className="min-w-0 flex-1">
<div
role="slider"
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
aria-valuenow={Math.round(current)}
tabIndex={0}
onClick={seek}
onKeyDown={(e) => {
const el = video ? videoRef.current : audioRef.current;
if (!el || !Number.isFinite(el.duration)) return;
if (e.key === "ArrowRight")
el.currentTime = Math.min(el.duration, el.currentTime + 5);
if (e.key === "ArrowLeft")
el.currentTime = Math.max(0, el.currentTime - 5);
}}
className="group relative h-4 cursor-pointer"
>
<div className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 overflow-hidden rounded-full bg-surface">
<div
className={cn(
"h-full rounded-full transition-[width]",
(playing || buffering) && "bg-signal/80",
!playing && !buffering && "bg-signal/40",
)}
style={{ width: `${pct}%` }}
/>
</div>
{(playing || buffering) && (
<span
className="absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-signal shadow-[0_0_8px_var(--color-signal-glow)] transition-[left]"
style={{ left: `${pct}%` }}
/>
)}
</div>
<div className="mono mt-1 flex items-center justify-between text-[0.6rem] text-ink-faint">
<span>{formatTime(current)}</span>
{/* equalizer bars while playing */}
{(playing || buffering) && (
<span className="flex h-3 items-end gap-[2px]" aria-hidden>
{[0, 1, 2, 3].map((i) => (
<span
key={`eq-${i}`}
className="w-[3px] animate-eq rounded-full bg-signal"
style={{ animationDelay: `${i * 140}ms`, height: "100%" }}
/>
))}
</span>
)}
<span className="inline-flex items-center gap-1">
<Volume2 className="size-3" />
{formatTime(duration)}
</span>
</div>
</div>
</div>
</div>
);
}
/** Small "now playing" chip used by the card header. */
export function NowPlayingChip() {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-signal/40 bg-signal/10 px-2 py-0.5 text-[0.6rem] font-medium text-signal">
<Signal className="size-3 animate-pulse" />
now playing
</span>
);
}
@@ -1,135 +0,0 @@
"use client";
import { Radio } from "lucide-react";
import { useEffect, useRef } from "react";
import { Avatar } from "@/components/primitives";
import type { ActiveSpeaker } from "@/lib/types";
export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) {
const containerRef = useRef<HTMLDivElement>(null);
// Only show actively speaking users on the stage orbit
const activeSpeakers = speakers.filter((s) => s.speaking);
const n = activeSpeakers.length;
const totalConnected = speakers.length;
const live = n > 0;
// CSS stagger reveal for speaker nodes
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const nodes = container.querySelectorAll<HTMLElement>(".speaker-node");
if (nodes.length === 0) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
nodes.forEach((el, i) => {
el.style.opacity = "0";
el.style.animationFillMode = "forwards";
el.style.animationTimingFunction = "cubic-bezier(0.34, 1.56, 0.64, 1)";
el.style.animationName = "scale-bounce-in";
el.style.animationDuration = "0.4s";
el.style.animationDelay = `${i * 0.05}s`;
});
return () => {
nodes.forEach((el) => {
el.style.removeProperty("opacity");
el.style.removeProperty("animation-name");
el.style.removeProperty("animation-duration");
el.style.removeProperty("animation-delay");
el.style.removeProperty("animation-fill-mode");
el.style.removeProperty("animation-timing-function");
});
};
}, []);
// CSS pulse ring for active speakers
useEffect(() => {
const container = containerRef.current;
if (!container) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const rings = container.querySelectorAll<HTMLElement>(
".speaker-pulse-ring",
);
if (rings.length === 0) return;
rings.forEach((el, i) => {
el.style.animationName = "pulse-ring";
el.style.animationDuration = "1.1s";
el.style.animationTimingFunction = "sine.in-out";
el.style.animationIterationCount = "infinite";
el.style.animationDelay = `${i * 0.08}s`;
});
return () => {
rings.forEach((el) => {
el.style.removeProperty("animation-name");
el.style.removeProperty("animation-duration");
el.style.removeProperty("animation-timing-function");
el.style.removeProperty("animation-iteration-count");
el.style.removeProperty("animation-delay");
});
};
}, []);
return (
<div
ref={containerRef}
className="relative mx-auto aspect-square w-full max-w-[380px]"
>
<div className="absolute inset-4 rounded-full border border-hairline/30" />
<div className="absolute inset-12 rounded-full border border-dashed border-hairline/40 animate-spin-disc radar-sweep" />
<div className="absolute left-1/2 top-1/2 size-80 -translate-x-1/2 -translate-y-1/2 rounded-full border border-hairline/20" />
<div
className="absolute left-1/2 top-1/2 size-56 -translate-x-1/2 -translate-y-1/2 rounded-full bg-signal/5 transition-opacity duration-500"
style={{ opacity: live ? 1 : 0.2 }}
/>
<div
className="absolute left-1/2 top-1/2 flex size-28 -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center rounded-full border backdrop-blur-md transition-all duration-300"
style={{
borderColor: live ? "var(--color-signal)" : "var(--color-hairline)",
boxShadow: live ? "0 0 50px -8px var(--color-signal-glow)" : "none",
background: "oklch(0.15 0.02 70 / 0.7)",
}}
>
<Radio
className={`size-7 transition-colors ${live ? "text-signal animate-breathe" : "text-ink-faint"}`}
/>
<span className="font-mono mt-1 text-[11px] font-bold tracking-wider text-ink uppercase">
{live ? `${n} SPEAKING` : `${totalConnected} CONNECTED`}
</span>
</div>
{activeSpeakers.map((s, i) => {
const angle = (i / Math.max(n, 1)) * Math.PI * 2 - Math.PI / 2;
const radius = 44;
const x = 50 + radius * Math.cos(angle);
const y = 50 + radius * Math.sin(angle);
return (
<div
key={s.userId}
className="speaker-node absolute -translate-x-1/2 -translate-y-1/2 transition-all duration-300"
style={{ left: `${x}%`, top: `${y}%` }}
>
<div className="relative flex flex-col items-center gap-1.5">
<span className="relative">
<Avatar
src={s.avatar}
name={s.username}
size={50}
ring={s.speaking}
/>
{s.speaking && (
<span className="speaker-pulse-ring absolute -inset-1 rounded-full ring-2 ring-signal ring-offset-2 ring-offset-canvas" />
)}
</span>
<span className="font-mono max-w-[100px] truncate rounded-md border border-hairline bg-canvas-2/90 px-2 py-0.5 text-[10px] font-semibold text-ink shadow-md backdrop-blur-md">
{s.username}
</span>
</div>
</div>
);
})}
</div>
);
}
-26
View File
@@ -12,14 +12,6 @@ export {
} from "./use-dashboard";
export { useGuilds } from "./use-guilds";
export { useChannelCultures, useGlossary } from "./use-knowledge";
export {
useMediaLoop,
useMediaQueue,
useMediaSkip,
useMediaState,
useMediaStop,
useMediaWsSync,
} from "./use-media";
export {
useImages,
useLoadMore,
@@ -47,21 +39,3 @@ export {
useTopFlaggedChannels,
useTopFlaggedDomains,
} from "./use-moderation";
export {
type RecordingsFilter,
useDeleteRecording,
useLoadMoreRecordings,
useRecordings,
useRecordingsSummary,
useRecordingsWsSync,
} from "./use-recordings";
export {
hashUserId,
useMicTransmit,
useSpeakers,
useVoiceChannels,
useVoiceConnect,
useVoiceDisconnect,
useVoiceListen,
useVoiceStatus,
} from "./use-voice";
+4 -3
View File
@@ -1,13 +1,14 @@
import useSWR from "swr";
import { voiceApi } from "@/lib/api";
import { messagesApi } from "@/lib/api";
import type { Guild } from "@/lib/types";
/**
* Fetch the list of available Discord guilds.
* Fetch the list of available Discord guilds (derived from the message
* archive by the backend).
*/
export function useGuilds(initialData?: Guild[]) {
return useSWR<Guild[]>(["guilds"], () => voiceApi.getGuilds(), {
return useSWR<Guild[]>(["guilds"], () => messagesApi.getGuilds(), {
dedupingInterval: 60_000,
fallbackData: initialData,
});
-54
View File
@@ -1,54 +0,0 @@
import { useEffect } from "react";
import useSWR, { useSWRConfig } from "swr";
import { useAction } from "@/hooks/use-action";
import { mediaApi } from "@/lib/api";
import type { MediaState } from "@/lib/types";
import type { WsHook } from "@/lib/ws-hook";
const MEDIA_KEY = ["media-state"] as const;
export function useMediaState(initialData?: MediaState) {
return useSWR<MediaState>(MEDIA_KEY, () => mediaApi.getStatus(), {
refreshInterval: 10_000,
shouldRetryOnError: false,
fallbackData: initialData,
});
}
function useMediaAction<TArgs>(fn: (args: TArgs) => Promise<MediaState>) {
const { mutate } = useSWRConfig();
return useAction(fn, {
onSuccess: (data) => {
void mutate(MEDIA_KEY, data, { revalidate: false });
},
});
}
export function useMediaQueue() {
return useMediaAction((input: { url: string; mode?: "music" | "screen" }) =>
mediaApi.queue(input.url, input.mode ?? "music"),
);
}
export function useMediaSkip() {
return useMediaAction(() => mediaApi.skip());
}
export function useMediaStop() {
return useMediaAction(() => mediaApi.stop());
}
export function useMediaLoop() {
return useMediaAction((loop: boolean) => mediaApi.loop(loop));
}
/** Subscribe to WS media_state events to keep cache fresh */
export function useMediaWsSync(ws: WsHook) {
const { mutate } = useSWRConfig();
useEffect(() => {
const unsub = ws.on("media_state", (data) => {
void mutate(MEDIA_KEY, data as MediaState, { revalidate: false });
});
return unsub;
}, [ws, mutate]);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import useSWR, { useSWRConfig } from "swr";
import { useAction } from "@/hooks/use-action";
import { messagesApi, voiceApi } from "@/lib/api";
import { messagesApi } from "@/lib/api";
import type {
AttachmentRecord,
Channel,
@@ -108,7 +108,7 @@ export function useLoadMore() {
export function useTextChannels(guildId: string) {
return useSWR<Channel[]>(guildId ? ["text-channels", guildId] : null, () =>
voiceApi.getTextChannels(guildId),
messagesApi.getTextChannels(guildId),
);
}
@@ -1,170 +0,0 @@
import { useEffect } from "react";
import useSWR, { useSWRConfig } from "swr";
import { useAction } from "@/hooks/use-action";
import { type RecordingListParams, recordingsApi } from "@/lib/api/recordings";
import type {
PaginatedRecordings,
SpeakerSummary,
VoiceRecording,
} from "@/lib/types";
import type { WsHook } from "@/lib/ws-hook";
const RECORDINGS_PREFIX = "recordings";
const RECORDINGS_SUMMARY_KEY = ["recordings", "summary"] as const;
export interface RecordingsFilter {
channelId?: string;
userId?: string;
/** keyword search */
q?: string;
startDate?: number;
endDate?: number;
}
function recordingsKey(filter: RecordingsFilter = {}): unknown[] {
return [
RECORDINGS_PREFIX,
"list",
filter.channelId ?? "",
filter.userId ?? "",
filter.q ?? "",
filter.startDate ?? "",
filter.endDate ?? "",
];
}
/** Match every recordings-list cache key so mutation/sync hits all filters. */
function isRecordingsListKey(key: unknown): boolean {
return (
Array.isArray(key) &&
key.length === 7 &&
key[0] === RECORDINGS_PREFIX &&
key[1] === "list"
);
}
function baseParams(filter: RecordingsFilter = {}): RecordingListParams {
return {
channelId: filter.channelId,
userId: filter.userId,
q: filter.q,
startDate: filter.startDate,
endDate: filter.endDate,
};
}
export function useRecordingsPage(
initialPage?: PaginatedRecordings,
filter: RecordingsFilter = {},
) {
return useSWR<PaginatedRecordings>(
recordingsKey(filter),
() => recordingsApi.list({ ...baseParams(filter), limit: 50 }),
// fallbackData only applies to the unfiltered "all" view; a filtered cache
// must not be pre-seeded with unfiltered rows (would flash wrong data).
{
fallbackData:
filter.channelId ||
filter.userId ||
filter.q ||
filter.startDate ||
filter.endDate
? undefined
: initialPage,
},
);
}
export function useRecordings(
initialPage?: PaginatedRecordings,
filter: RecordingsFilter = {},
) {
const page = useRecordingsPage(initialPage, filter);
return {
...page,
data: page.data?.items,
nextCursor: page.data?.nextCursor ?? null,
hasMore: page.data?.hasMore ?? false,
refetch: () => page.mutate(),
};
}
export function useLoadMoreRecordings(filter: RecordingsFilter = {}) {
const { mutate } = useSWRConfig();
return useAction(async ({ cursor }: { cursor: string }) => {
const result = await recordingsApi.list({
...baseParams(filter),
limit: 50,
cursor,
});
await mutate(
recordingsKey(filter),
(old: PaginatedRecordings | undefined): PaginatedRecordings => {
if (!old) return result;
const existingIds = new Set(old.items.map((r) => r.id));
const newUnique = result.items.filter((r) => !existingIds.has(r.id));
return {
items: [...old.items, ...newUnique],
nextCursor: result.nextCursor,
hasMore: result.hasMore,
};
},
{ revalidate: false },
);
return result;
});
}
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(
(key: unknown) => isRecordingsListKey(key),
(
old: PaginatedRecordings | undefined,
): PaginatedRecordings | undefined => {
if (!old) return old;
return {
...old,
items: old.items.filter((r) => r.id !== id),
};
},
{ revalidate: false },
);
// Leaderboard totals shifted — refresh.
void mutate(RECORDINGS_SUMMARY_KEY);
},
});
}
export function useRecordingsWsSync(ws: WsHook) {
const { mutate } = useSWRConfig();
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(
(key: unknown) => isRecordingsListKey(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;
return {
...old,
items: [rec, ...old.items],
};
},
{ revalidate: false },
);
void mutate(RECORDINGS_SUMMARY_KEY);
});
return unsub;
}, [ws, mutate]);
}
export function useRecordingsSummary() {
return useSWR<SpeakerSummary[]>(RECORDINGS_SUMMARY_KEY, () =>
recordingsApi.summary(),
);
}
-257
View File
@@ -1,257 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import useSWR, { useSWRConfig } from "swr";
import { useAction } from "@/hooks/use-action";
import { voiceApi } from "@/lib/api";
import { MicTransmitter } from "@/lib/audio/mic-transmit";
import { PcmPlayer } from "@/lib/audio/pcm-player";
import { hashUserId } from "@/lib/hash";
import type { ActiveSpeaker, Channel, VoiceStatus } from "@/lib/types";
import type { PcmChunk } from "@/lib/ws/types";
import type { WsHook } from "@/lib/ws-hook";
// Re-export for components that still import hashUserId from this module.
export { hashUserId };
const STATUS_KEY = ["voice-status"] as const;
export function useVoiceStatus(initialData?: VoiceStatus) {
return useSWR<VoiceStatus>(STATUS_KEY, () => voiceApi.getStatus(), {
shouldRetryOnError: false,
fallbackData: initialData,
refreshInterval: 4000,
});
}
export function useVoiceChannels(guildId: string) {
return useSWR<Channel[]>(guildId ? ["voice-channels", guildId] : null, () =>
voiceApi.getVoiceChannels(guildId),
);
}
const SPEAKERS_KEY = ["voice-speakers"] as const;
/**
* Live shared speaker state.
*
* Seeded from the server-authored snapshot (`initial` — the voice status the
* server rendered, which includes the authoritative active speakers). From
* there the WS keeps it converged across ALL users:
* - `voice_state` → authoritative FULL replacement (e.g. a late join seeds
* every client with the same list);
* - `voice_active_user` → incremental upsert of a single speaker delta.
*
* Now backed by SWR (consistent with all other hooks) so cache, revalidation,
* and deduping apply. WS events mutate the SWR cache directly
* ({ revalidate: false }) to avoid refetching the full status.
*
* This replaces the old per-browser model where each tab accumulated speakers
* only from events it happened to receive while mounted.
*/
const SPEAKER_TTL_MS = 30_000;
/** Remove speakers that haven't been active recently. */
function filterStale(speakers: ActiveSpeaker[]): ActiveSpeaker[] {
const now = Date.now();
return speakers.filter((s) => {
if (s.speaking) return true;
if (s.lastActiveAt && now - s.lastActiveAt > SPEAKER_TTL_MS) return false;
return true;
});
}
export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) {
const {
data: speakers,
error,
mutate,
isValidating,
} = useSWR<ActiveSpeaker[]>(SPEAKERS_KEY, () => Promise.resolve([]), {
fallbackData: initialStatusActive ?? [],
revalidateOnMount: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
});
const subscribe = useCallback(
(ws: WsHook) => {
const unsubSnapshot = ws.on("voice_state", (data) => {
const state = data as { activeSpeakers?: ActiveSpeaker[] };
if (Array.isArray(state?.activeSpeakers)) {
void mutate(filterStale(state.activeSpeakers), { revalidate: false });
}
});
const unsub = ws.on("voice_active_user", (data) => {
const speaker = data as ActiveSpeaker;
void mutate(
(prev: ActiveSpeaker[] | undefined) => {
const arr = prev ?? [];
const idx = arr.findIndex((s) => s.userId === speaker.userId);
const next = [...arr];
if (idx >= 0) {
next[idx] = speaker;
} else {
next.push(speaker);
}
return filterStale(next);
},
{ revalidate: false },
);
});
return () => {
unsubSnapshot();
unsub();
};
},
[mutate],
);
// Periodic stale speaker cleanup (every 10s)
useEffect(() => {
const timer = setInterval(() => {
void mutate((prev) => (prev ? filterStale(prev) : prev), {
revalidate: false,
});
}, 10_000);
return () => clearInterval(timer);
}, [mutate]);
return { speakers: speakers ?? [], subscribe, error, isValidating };
}
function useStatusInvalidator() {
const { mutate } = useSWRConfig();
return useCallback(() => {
void mutate(STATUS_KEY);
}, [mutate]);
}
export function useVoiceConnect() {
const invalidate = useStatusInvalidator();
return useAction(
({ guildId, channelId }: { guildId: string; channelId: string }) =>
voiceApi.connect(guildId, channelId),
{ onSuccess: invalidate },
);
}
export function useVoiceDisconnect() {
const invalidate = useStatusInvalidator();
return useAction(() => voiceApi.disconnect(), { onSuccess: invalidate });
}
export function useMicTransmit(ws: {
sendBinary: (data: ArrayBufferLike) => void;
}) {
const transmitterRef = useRef<MicTransmitter | null>(null);
const [micLevel, setMicLevel] = useState(0);
const [noiseSuppression, setNoiseSuppressionState] = useState(true);
const action = useAction(async (active: boolean) => {
if (active) {
const transmitter = new MicTransmitter((frame) => ws.sendBinary(frame), {
noiseSuppression,
});
transmitterRef.current = transmitter;
await transmitter.start();
await voiceApi.sendCommand("voice:transmit:start");
} else {
transmitterRef.current?.stop();
transmitterRef.current = null;
setMicLevel(0);
await voiceApi.sendCommand("voice:transmit:stop");
}
});
const setVolume = useCallback((volume: number) => {
transmitterRef.current?.setVolume(volume / 100);
}, []);
const toggleNoiseSuppression = useCallback((enabled: boolean) => {
setNoiseSuppressionState(enabled);
// If mic is already active, toggling NS requires restart
if (transmitterRef.current?.isActive) {
transmitterRef.current.setNoiseSuppression(enabled);
}
}, []);
// Poll the analyser RMS so the UI can render a live input meter.
useEffect(() => {
const timer = setInterval(() => {
setMicLevel(transmitterRef.current?.getLevel() ?? 0);
}, 120);
return () => clearInterval(timer);
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
transmitterRef.current?.stop();
transmitterRef.current = null;
};
}, []);
return {
...action,
setVolume,
micLevel,
noiseSuppression,
toggleNoiseSuppression,
};
}
/**
* Receive + play Discord voice in the browser.
*
* Toggling on (from a user gesture) starts a PcmPlayer, subscribes to the WS
* binary PCM stream, and exposes per-user activity levels for the waveform UI.
* `toggle(false)` tears everything down.
*/
export function useVoiceListen(ws: {
onPcm: (handler: (chunk: PcmChunk) => void) => () => void;
}) {
const playerRef = useRef<PcmPlayer | null>(null);
const unsubRef = useRef<(() => void) | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [active, setActive] = useState(false);
const [levels, setLevels] = useState<Map<number, number>>(new Map());
const stop = useCallback(() => {
unsubRef.current?.();
unsubRef.current = null;
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
playerRef.current?.stop();
playerRef.current = null;
setActive(false);
setLevels(new Map());
}, []);
const toggle = useCallback(
(on: boolean) => {
if (on) {
const player = new PcmPlayer();
playerRef.current = player;
player.setVolume(0.75);
player.start(); // called from the click gesture
unsubRef.current = ws.onPcm((chunk) => {
player.push(chunk.userIdHash, chunk.samples);
});
timerRef.current = setInterval(() => {
setLevels(player.getLevels());
}, 100);
setActive(true);
} else {
stop();
}
},
[ws, stop],
);
const setVolume = useCallback((v: number) => {
playerRef.current?.setVolume(v / 100);
}, []);
useEffect(() => stop, [stop]);
return { active, levels, toggle, setVolume };
}
-3
View File
@@ -7,10 +7,8 @@ export { chatbotApi } from "./chatbot";
export { configApi } from "./config";
export { dashboardApi } from "./dashboard";
export { knowledgeApi } from "./knowledge";
export { mediaApi } from "./media";
export { messagesApi } from "./messages";
export { moderationApi } from "./moderation";
export { recordingsApi } from "./recordings";
// Re-export server-side fetchers for use inside React Server Components.
export {
getChannelCultures,
@@ -18,4 +16,3 @@ export {
getRecentEdits,
} from "./server";
export { uiStateApi } from "./ui-state";
export { voiceApi } from "./voice";
-12
View File
@@ -1,12 +0,0 @@
import { orpc } from "@/lib/orpc/client";
import type { MediaState } from "@/lib/types";
export const mediaApi = {
getStatus: () => orpc.media.status() as unknown as Promise<MediaState>,
queue: (source: string, mode: string) =>
orpc.media.queue({ source, mode }) as unknown as Promise<MediaState>,
skip: () => orpc.media.skip() as unknown as Promise<MediaState>,
stop: () => orpc.media.stop() as unknown as Promise<MediaState>,
loop: (loop: boolean) =>
orpc.media.loop({ loop }) as unknown as Promise<MediaState>,
};
@@ -1,13 +1,20 @@
import { orpc } from "@/lib/orpc/client";
import type {
AttachmentRecord,
Channel,
EditHistoryRow,
Guild,
MessageActivityBucket,
MessageRecord,
SemanticSearchResult,
} from "@/lib/types";
export const messagesApi = {
getGuilds: () => orpc.messages.guilds() as unknown as Promise<Guild[]>,
getTextChannels: (guildId: string) =>
orpc.messages.textChannels({ guildId }) as unknown as Promise<Channel[]>,
list: (
guildId: string,
limit?: number,
@@ -1,34 +0,0 @@
import { orpc } from "@/lib/orpc/client";
import type { PaginatedRecordings, SpeakerSummary } from "@/lib/types";
export interface RecordingListParams {
limit?: number;
channelId?: string;
userId?: string;
cursor?: string;
/** keyword search against transcription + username */
q?: string;
/** created_at lower bound (epoch ms) */
startDate?: number;
/** created_at upper bound (epoch ms) */
endDate?: number;
}
export const recordingsApi = {
list: (params: RecordingListParams = {}) =>
orpc.recordings.list({
limit: params.limit,
channelId: params.channelId,
userId: params.userId,
cursor: params.cursor,
q: params.q,
startDate: params.startDate,
endDate: params.endDate,
}) as unknown as Promise<PaginatedRecordings>,
delete: (id: string) =>
orpc.recordings.delete({ id }) as unknown as Promise<{ ok: boolean }>,
summary: () =>
orpc.recordings.summary({}) as unknown as Promise<SpeakerSummary[]>,
};
+2 -20
View File
@@ -25,13 +25,10 @@ import type {
GlossaryRow,
Guild,
HourlyModeration,
MediaState,
ModerationCoverage,
ModerationStats,
ModerationTrends,
PaginatedModerationActions,
PaginatedRecordings,
VoiceStatus,
} from "@/lib/types";
import type { ORPCClient } from "../orpc/types";
@@ -77,11 +74,6 @@ export async function getUsers(limit = 20) {
}) as unknown as Promise<import("@/lib/types").PaginatedUsers>;
}
// ---- Media ----
export async function getMediaStatus(): Promise<MediaState> {
return serverOrpc().media.status() as unknown as Promise<MediaState>;
}
// ---- Config ----
export async function getConfig(): Promise<AppConfig> {
return serverOrpc().config.get() as unknown as Promise<AppConfig>;
@@ -125,19 +117,9 @@ export async function getCoverage(days = 30) {
}) as unknown as ModerationCoverage;
}
// ---- Voice ----
// ---- Guilds (from message archive) ----
export async function getGuilds(): Promise<Guild[]> {
return serverOrpc().voice.guilds() as unknown as Promise<Guild[]>;
}
export async function getVoiceStatus(): Promise<VoiceStatus> {
return serverOrpc().voice.status() as unknown as Promise<VoiceStatus>;
}
// ---- Recordings ----
export async function getRecordings(limit = 50): Promise<PaginatedRecordings> {
return serverOrpc().recordings.list({
limit,
}) as unknown as Promise<PaginatedRecordings>;
return serverOrpc().messages.guilds() as unknown as Promise<Guild[]>;
}
// ---- Messages (SSR seed for the streaming view) ----
-22
View File
@@ -1,22 +0,0 @@
import { orpc } from "@/lib/orpc/client";
import type { Channel, Guild, VoiceStatus } from "@/lib/types";
export const voiceApi = {
// Guilds
getGuilds: () => orpc.voice.guilds() as unknown as Promise<Guild[]>,
getTextChannels: (guildId: string) =>
orpc.voice.textChannels({ guildId }) as unknown as Promise<Channel[]>,
getVoiceChannels: (guildId: string) =>
orpc.voice.voiceChannels({ guildId }) as unknown as Promise<Channel[]>,
// Voice connection
getStatus: () => orpc.voice.status() as unknown as Promise<VoiceStatus>,
connect: (guildId: string, channelId: string) =>
orpc.voice.connect({
guildId,
channelId,
}) as unknown as Promise<VoiceStatus>,
disconnect: () => orpc.voice.disconnect() as unknown as Promise<VoiceStatus>,
sendCommand: (command: string) =>
orpc.voice.command({ command }) as unknown as Promise<unknown>,
};
@@ -1,253 +0,0 @@
/**
* Browser mic → Discord voice transmit.
*
* Pipeline: getUserMedia → AudioContext (48kHz) → AudioWorklet (downsample to
* 24kHz mono s16le, apply volume, chunk 20ms) → binary WS frames.
*
* The backend expects each binary frame to start with a 4-byte magic "PCM\0"
* followed by raw Int16LE PCM; it base64s the payload and publishes to Redis,
* where the gateway's VoiceTransmitter feeds it into FFmpeg (24kHz mono s16le
* → OggOpus) and plays it in the voice channel.
*/
const PCM_MAGIC = new Uint8Array([0x50, 0x43, 0x4d, 0x00]); // "PCM\0"
const TARGET_RATE = 24000;
const CHUNK_MS = 20;
// Inline AudioWorklet processor (Blob URL — works with Next static export,
// no asset pipeline needed).
const WORKLET_SRC = `
class PcmDownsampler extends AudioWorkletProcessor {
constructor(options) {
super();
const opts = options.processorOptions || {};
this.targetRate = opts.targetRate || 24000;
this.ratio = sampleRate / this.targetRate;
this.chunkSamples = Math.floor((this.targetRate * (opts.chunkMs || 20)) / 1000);
this.phase = 0;
this.buffer = new Int16Array(this.chunkSamples);
this.bufferLen = 0;
this.volume = typeof opts.volume === 'number' ? opts.volume : 1;
this.port.onmessage = (e) => {
if (e.data && e.data.type === 'volume') this.volume = e.data.value;
};
}
process(inputs) {
const input = inputs[0];
if (!input || input.length === 0) return true;
// Mixdown: average available channels
const chans = input.filter((c) => c && c.length > 0);
if (chans.length === 0) return true;
const len = chans[0].length;
for (let i = 0; i < len; i++) {
let s = 0;
for (let c = 0; c < chans.length; c++) s += chans[c][i];
s /= chans.length;
this.phase += 1;
if (this.phase >= this.ratio) {
this.phase -= this.ratio;
const v = Math.max(-1, Math.min(1, s * this.volume));
this.buffer[this.bufferLen++] = (v * 32767) | 0;
if (this.bufferLen >= this.chunkSamples) {
const out = new Int16Array(this.buffer);
this.port.postMessage(out.buffer, [out.buffer]);
this.buffer = new Int16Array(this.chunkSamples);
this.bufferLen = 0;
}
}
}
return true;
}
}
registerProcessor('pcm-downsampler', PcmDownsampler);
`;
/** Mic access error with user-actionable detail. */
export class MicAccessError extends Error {
constructor(
message: string,
public readonly reason:
| "not-supported"
| "permission-denied"
| "no-mic"
| "timeout"
| "unknown",
) {
super(message);
this.name = "MicAccessError";
}
}
export interface MicTransmitterOptions {
/** Enable browser-level noise suppression (default: true). */
noiseSuppression?: boolean;
/** Enable echo cancellation (default: true). */
echoCancellation?: boolean;
/** Enable auto gain control (default: true). */
autoGainControl?: boolean;
}
export class MicTransmitter {
private ctx: AudioContext | null = null;
private stream: MediaStream | null = null;
private node: AudioWorkletNode | null = null;
private analyser: AnalyserNode | null = null;
private levelBuf: Float32Array<ArrayBuffer> | null = null;
private active = false;
private volume = 1;
private noiseSuppression = true;
constructor(
private readonly onChunk: (frame: ArrayBuffer) => void,
private readonly options: MicTransmitterOptions = {},
) {
this.noiseSuppression = options.noiseSuppression ?? true;
}
get isActive(): boolean {
return this.active;
}
get isNoiseSuppressionEnabled(): boolean {
return this.noiseSuppression;
}
async start(volume = 1): Promise<void> {
if (this.active) return;
this.volume = volume;
// ── Check getUserMedia support ───────────────────────────────────────
if (!navigator.mediaDevices?.getUserMedia) {
throw new MicAccessError(
"getUserMedia is not available — are you on HTTPS or localhost?",
"not-supported",
);
}
// ── Request mic with noise suppression constraints ───────────────────
try {
this.stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: this.options.echoCancellation ?? true,
noiseSuppression: this.noiseSuppression,
autoGainControl: this.options.autoGainControl ?? true,
},
});
} catch (err) {
if (err instanceof DOMException) {
if (
err.name === "NotAllowedError" ||
err.name === "PermissionDeniedError"
) {
throw new MicAccessError(
"Microphone access denied — allow mic permission in your browser",
"permission-denied",
);
}
if (
err.name === "NotFoundError" ||
err.name === "DevicesNotFoundError"
) {
throw new MicAccessError(
"No microphone found — connect a mic and try again",
"no-mic",
);
}
if (err.name === "OverconstrainedError") {
throw new MicAccessError(
"Microphone does not support the requested constraints",
"unknown",
);
}
if (err.name === "AbortError" || err.name === "TimeoutError") {
throw new MicAccessError(
"Microphone access timed out — try again",
"timeout",
);
}
}
throw new MicAccessError(
`Failed to access microphone: ${err instanceof Error ? err.message : String(err)}`,
"unknown",
);
}
this.ctx = new AudioContext({ sampleRate: 48000 });
const blob = new Blob([WORKLET_SRC], { type: "application/javascript" });
const workletUrl = URL.createObjectURL(blob);
try {
await this.ctx.audioWorklet.addModule(workletUrl);
} finally {
URL.revokeObjectURL(workletUrl);
}
const source = this.ctx.createMediaStreamSource(this.stream);
this.node = new AudioWorkletNode(this.ctx, "pcm-downsampler", {
processorOptions: {
targetRate: TARGET_RATE,
chunkMs: CHUNK_MS,
volume: this.volume,
},
});
this.node.port.onmessage = (e: MessageEvent<ArrayBuffer>) => {
if (!this.active || !(e.data instanceof ArrayBuffer)) return;
const frame = new Uint8Array(PCM_MAGIC.length + e.data.byteLength);
frame.set(PCM_MAGIC, 0);
frame.set(new Uint8Array(e.data), PCM_MAGIC.length);
this.onChunk(frame.buffer);
};
source.connect(this.node);
// Level metering tap: analyser reads the raw mic (pre-volume) so the UI
// shows what the mic actually hears. Silent sink keeps the graph alive.
this.analyser = this.ctx.createAnalyser();
this.analyser.fftSize = 1024;
this.levelBuf = new Float32Array(this.analyser.fftSize);
source.connect(this.analyser);
// Keep the graph alive with an inaudible tail (silent gain) so the
// worklet keeps pulling mic data without audible feedback.
const silent = this.ctx.createGain();
silent.gain.value = 0;
this.node.connect(silent);
silent.connect(this.ctx.destination);
this.active = true;
}
/** RMS mic level 0..1 since the last call (drives the live meter UI). */
getLevel(): number {
if (!this.analyser || !this.levelBuf) return 0;
this.analyser.getFloatTimeDomainData(this.levelBuf);
let sum = 0;
for (let i = 0; i < this.levelBuf.length; i++) sum += this.levelBuf[i] ** 2;
return Math.min(1, Math.sqrt(sum / this.levelBuf.length) * 4);
}
setVolume(volume: number): void {
this.volume = volume;
this.node?.port.postMessage({ type: "volume", value: volume });
}
/** Toggle noise suppression. Requires restart to take effect. */
setNoiseSuppression(enabled: boolean): void {
this.noiseSuppression = enabled;
}
stop(): void {
this.active = false;
this.node?.port.postMessage({ type: "volume", value: 0 });
this.node?.disconnect();
this.node = null;
this.analyser?.disconnect();
this.analyser = null;
this.levelBuf = null;
this.stream?.getTracks().forEach((t) => t.stop());
this.stream = null;
this.ctx?.close().catch(() => {});
this.ctx = null;
}
}
@@ -1,153 +0,0 @@
/**
* Receive Discord voice PCM over the WebSocket and play it through the
* browser audio stack.
*
* Binary frame format (from the gateway, via backend WS):
* Byte 0–3: FNV-1a 32-bit userId hash (UInt32LE)
* Byte 4+: PCM audio (24kHz mono Int16LE)
*
* The player keeps one ring buffer per user (2s @ 24kHz), upsamples
* 24k → 48k with linear interpolation inside the audio callback, and mixes
* every active user into a single mono output. A ScriptProcessorNode is used
* because it runs on the main thread — ring buffers need no SharedArrayBuffer
* and work without COOP/COEP headers. `start()` must be called from a user
* gesture (audio autoplay policy).
*/
const INPUT_RATE = 24000;
const OUTPUT_RATE = 48000;
const RING_SECONDS = 2;
const RING_LEN = INPUT_RATE * RING_SECONDS;
interface UserRing {
data: Float32Array;
/** absolute write position (monotonic) */
write: number;
/** absolute read position as float — advances at 0.5× per output sample */
readPos: number;
/** last tick where this user produced audio (for stale-ring cleanup) */
lastActive: number;
}
export class PcmPlayer {
private ctx: AudioContext | null = null;
private processor: ScriptProcessorNode | null = null;
private master: GainNode | null = null;
private rings = new Map<number, UserRing>();
private levels = new Map<number, number>();
private volume = 0.75;
private started = false;
get isStarted(): boolean {
return this.started;
}
/** Create the AudioContext + processor. MUST be called from a user gesture. */
start(): void {
if (this.started) return;
const Ctor =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
if (!Ctor) return;
this.ctx = new Ctor({ sampleRate: OUTPUT_RATE });
this.master = this.ctx.createGain();
this.master.gain.value = this.volume;
this.master.connect(this.ctx.destination);
this.processor = this.ctx.createScriptProcessor(4096, 0, 1);
this.processor.onaudioprocess = (e) => this.tick(e);
this.processor.connect(this.master);
this.started = true;
}
/** Push one PCM chunk (s16 mono @24kHz) for a user. */
push(userIdHash: number, samples: Int16Array): void {
if (!this.started || samples.length === 0) return;
let ring = this.rings.get(userIdHash);
if (!ring) {
ring = {
data: new Float32Array(RING_LEN),
write: 0,
readPos: 0,
lastActive: Date.now(),
};
this.rings.set(userIdHash, ring);
}
ring.lastActive = Date.now();
for (let i = 0; i < samples.length; i++) {
ring.data[ring.write % RING_LEN] = samples[i] / 32768;
ring.write++;
}
// Overflow guard: never let the ring lag more than RING_LEN behind.
// This handles both normal drift and extreme backpressure scenarios.
const lag = ring.write - ring.readPos;
if (lag > RING_LEN - 4096) {
ring.readPos = ring.write - RING_LEN + 4096;
}
}
setVolume(v: number): void {
this.volume = v;
if (this.master) this.master.gain.value = v;
}
/** Peak |sample| per user since the last call (drives the UI waveform). */
getLevels(): Map<number, number> {
return new Map(this.levels);
}
stop(): void {
if (this.processor) {
this.processor.disconnect();
this.processor.onaudioprocess = null;
}
if (this.ctx) {
void this.ctx.close().catch(() => {});
}
this.processor = null;
this.ctx = null;
this.master = null;
this.rings.clear();
this.levels.clear();
this.started = false;
}
private tick(e: AudioProcessingEvent): void {
const out = e.outputBuffer.getChannelData(0);
out.fill(0);
const n = out.length;
const step = INPUT_RATE / OUTPUT_RATE; // 0.5
const nextLevels = new Map<number, number>();
for (const [hash, ring] of this.rings) {
let level = 0;
for (let i = 0; i < n; i++) {
const pos = ring.readPos + i * step;
if (pos + 1 >= ring.write) break;
const i0 = Math.floor(pos);
const frac = pos - i0;
const a = ring.data[i0 % RING_LEN];
const b = ring.data[(i0 + 1) % RING_LEN];
const v = a + (b - a) * frac;
out[i] += v;
const av = Math.abs(v);
if (av > level) level = av;
}
ring.readPos += n * step;
if (ring.readPos > ring.write) ring.readPos = ring.write;
if (level > 0.001) {
ring.lastActive = Date.now();
nextLevels.set(hash, level);
}
}
// Drop users silent for >5s so the ring map doesn't grow unbounded.
const now = Date.now();
for (const [hash, ring] of this.rings) {
if (now - ring.lastActive > 5000) this.rings.delete(hash);
}
this.levels = nextLevels;
}
}
-126
View File
@@ -1,126 +0,0 @@
/**
* 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);
}
-12
View File
@@ -1,12 +0,0 @@
/**
* FNV-1a 32-bit hash — same hash the gateway uses to tag PCM frames.
* Shared between use-voice hooks and ambient-canvas.
*/
export function hashUserId(userId: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < userId.length; i++) {
hash ^= userId.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
+1 -6
View File
@@ -7,9 +7,7 @@ export interface Guild {
export interface Channel {
id: string;
name: string;
type: "voice" | "text";
/** Whether the selfbot account can actually join this voice channel. */
joinable?: boolean;
type: "text";
}
/** Shape of the /api/config response (camelCase keys from backend). */
@@ -21,10 +19,7 @@ export interface AppConfig {
backlogSyncBatchSize?: number;
retentionMessagesDays?: number;
retentionAttachmentsDays?: number;
retentionVoiceDays?: number;
autoDeleteFlaggedEnabled?: boolean;
aiAnalysisEnabled?: boolean;
voiceGuildId?: string | null;
voiceChannelId?: string | null;
logLevel?: string;
}

Some files were not shown because too many files have changed in this diff Show More