File size: 5,263 Bytes
7804693
 
 
 
 
ec1a96a
7804693
 
 
 
 
 
 
 
 
 
 
ec1a96a
 
 
7804693
 
 
 
 
 
 
 
 
 
 
ec1a96a
 
7804693
 
ec1a96a
 
 
 
 
 
 
 
 
 
 
7804693
 
 
ec1a96a
 
7804693
 
ec1a96a
7804693
 
ec1a96a
7804693
 
 
 
 
 
 
 
 
 
 
 
 
 
ec1a96a
 
7804693
 
ec1a96a
7804693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ec1a96a
7804693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import { useCallback, useEffect, useRef, useState } from "react";

/* ---------------------------------------------------------------------------------------
 * Microphone recorder with live level meter (for the waveform)
 * ------------------------------------------------------------------------------------- */
export type Recording = { blob: Blob; seconds: number; ext: string; video: boolean };

export function useRecorder(bars = 32) {
  const [state, setState] = useState<"idle" | "recording">("idle");
  const [levels, setLevels] = useState<number[]>(Array(bars).fill(0.08));
  const [seconds, setSeconds] = useState(0);
  const rec = useRef<MediaRecorder | null>(null);
  const stream = useRef<MediaStream | null>(null);
  const ctx = useRef<AudioContext | null>(null);
  const raf = useRef<number>(0);
  const started = useRef(0);
  const resolver = useRef<((r: Recording) => void) | null>(null);
  const rms = useRef(0);
  /** Current microphone level (0-1), for lip-sync checks. */
  const level = useCallback(() => rms.current, []);

  const cleanup = useCallback(() => {
    cancelAnimationFrame(raf.current);
    stream.current?.getTracks().forEach((t) => t.stop());
    stream.current = null;
    ctx.current?.close().catch(() => undefined);
    ctx.current = null;
  }, []);

  useEffect(() => cleanup, [cleanup]);

  /** Start recording. Pass the camera stream to record a video answer (low bitrate, ~2-3 MB per minute). */
  const start = useCallback(async (opts?: { video?: MediaStream | null }) => {
    const s = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
    stream.current = s;
    const videoTracks = opts?.video?.getVideoTracks().filter((t) => t.readyState === "live") ?? [];
    const withVideo = videoTracks.length > 0;
    const recStream = withVideo ? new MediaStream([...videoTracks, ...s.getAudioTracks()]) : s;
    const types = withVideo
      ? ["video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus", "video/webm", "video/mp4"]
      : ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg"];
    const mime = types.find((m) => window.MediaRecorder?.isTypeSupported(m)) || "";
    const r = new MediaRecorder(recStream, {
      ...(mime ? { mimeType: mime } : {}),
      ...(withVideo ? { videoBitsPerSecond: 350_000, audioBitsPerSecond: 64_000 } : {}),
    });
    const chunks: Blob[] = [];
    r.ondataavailable = (e) => e.data.size && chunks.push(e.data);
    r.onstop = () => {
      const type = r.mimeType || (withVideo ? "video/webm" : "audio/webm");
      const ext = type.includes("mp4") ? (withVideo ? ".mp4" : ".m4a") : type.includes("ogg") ? ".ogg" : ".webm";
      const secs = (Date.now() - started.current) / 1000;
      cleanup();
      rms.current = 0;
      setState("idle");
      setLevels(Array(bars).fill(0.08));
      resolver.current?.({ blob: new Blob(chunks, { type }), seconds: secs, ext, video: withVideo });
    };
    // level meter
    const ac = new AudioContext();
    ctx.current = ac;
    const an = ac.createAnalyser();
    an.fftSize = 512;
    ac.createMediaStreamSource(s).connect(an);
    const buf = new Uint8Array(an.fftSize);
    const hist: number[] = Array(bars).fill(0.08);
    let last = 0;
    const tick = (ts: number) => {
      an.getByteTimeDomainData(buf);
      let sum = 0;
      for (const v of buf) sum += ((v - 128) / 128) ** 2;
      const level = Math.sqrt(sum / buf.length);
      rms.current = level;
      if (ts - last > 70) {
        hist.shift();
        hist.push(Math.min(1, 0.08 + level * 4));
        setLevels([...hist]);
        setSeconds((Date.now() - started.current) / 1000);
        last = ts;
      }
      raf.current = requestAnimationFrame(tick);
    };
    raf.current = requestAnimationFrame(tick);
    started.current = Date.now();
    setSeconds(0);
    r.start(1000);
    rec.current = r;
    setState("recording");
  }, [bars, cleanup]);

  const stop = useCallback(
    () =>
      new Promise<Recording>((resolve) => {
        resolver.current = resolve;
        if (rec.current && rec.current.state !== "inactive") rec.current.stop();
      }),
    [],
  );

  return { state, levels, seconds, start, stop, level };
}

/* ---------------------------------------------------------------------------------------
 * Interviewer voice: the browser's speech synthesis
 * ------------------------------------------------------------------------------------- */
export function speak(text: string, onEnd?: () => void) {
  if (!("speechSynthesis" in window)) {
    onEnd?.();
    return;
  }
  speechSynthesis.cancel();
  const u = new SpeechSynthesisUtterance(text);
  u.lang = "en-US";
  u.rate = 0.97;
  const voices = speechSynthesis.getVoices();
  const v = voices.find((x) => /en-(US|GB)/.test(x.lang) && /Google|Natural|Samantha|Daniel/.test(x.name)) || voices.find((x) => x.lang?.startsWith("en"));
  if (v) u.voice = v;
  u.onend = () => onEnd?.();
  u.onerror = () => onEnd?.();
  speechSynthesis.speak(u);
}

export function stopSpeaking() {
  if ("speechSynthesis" in window) speechSynthesis.cancel();
}

export function fmtClock(s: number) {
  const v = Math.max(0, Math.round(s));
  return `${Math.floor(v / 60)}:${String(v % 60).padStart(2, "0")}`;
}