fix(voice-recording): stop missing start-of-burst audio & mid-burst splits

Root-cause fixes for 'banyak miss & terpotong' in the voice->recording flow:

- subscribe BEFORE collecting user metadata. receiver.speaking 'start' fires
  on the FIRST opus packet, and onUdpMessage forwards frames to the
  subscription only when one exists — every frame during the old
  await collectUserMetadata (a Discord REST roundtrip on cache miss) was
  dropped, cutting off the start of every burst. Now subscribe synchronously
  (guard first, no await in between), then fetch metadata in the background
  and discard the burst if the speaker turns out to be a bot.
- one segment per burst: drop the fixed 5s RECORDING_SEGMENT_MS rotation on
  the OGG path, which split continuous speech mid-word/sentence. Only the
  web-PCM decoder still rotates (bounds memory).
- finalize only once the underlying file has flushed to disk (wait on the
  write stream 'finish'), so upload/transcode reads a complete file.
- raise AfterSilence 3000->4000ms so natural pauses (thinking, interruptions)
  don't split one utterance into several recordings.
- lower the 'too short to keep' threshold 1000->300ms so brief replies
  ("ya", "siap") are kept instead of dropped.

All typecheck / biome(src/) / vitest (164) green.
This commit is contained in:
asepharyana
2026-08-30 12:23:03 +07:00
parent f0874d8634
commit e3016a858a
4 changed files with 346 additions and 96 deletions
@@ -53,10 +53,15 @@ export const configSchema = z
DECODER_COOLDOWN_MS: z.coerce.number().positive().default(30000),
// ── Audio ────────────────────────────────────────────────────────────
// AfterSilence: how long a voice burst may stay silent before the receive
// stream ends the segment. Raised 3000→4000 so natural pauses in speech
// (thinking gaps, interruptions) don't split one utterance into multiple
// segments ("terpotong"). Tunable via env; larger = fewer splits but a
// longer silent tail on each recording.
AUDIO_STREAM_SILENCE_DURATION_MS: z.coerce
.number()
.positive()
.default(3000),
.default(4000),
PACKET_FILTER_MIN_SIZE: z.coerce.number().positive().default(8),
OPUS_FRAME_SIZE: z.coerce.number().positive().default(960),
AUDIO_SAMPLE_RATE: z.coerce.number().positive().default(48000),