assets/js/voice_recording.mjs

58e6347eeb72 · 6 KB

// Browser-side capture of a call, because the server never sees the audio.
//
// Media flows browser-to-OpenAI over WebRTC and Phoenix holds only a lifecycle
// sideband, so the only place both sides of a call exist together is here. The
// microphone becomes the left channel and Sarah's track the right, which keeps
// the two voices attributable in one file instead of doubling storage.
//
// Nothing in here may take a call down. Every failure path stops recording,
// reports it, and leaves the conversation running.

export const RECORDING_MIME_CANDIDATES = [
  "audio/webm;codecs=opus",
  "audio/webm",
  "audio/mp4",
  "audio/ogg;codecs=opus",
]

// Safari's WebM support is the weak spot, so the container is discovered rather
// than assumed and the server stores whichever one arrived.
export const supportedRecordingMimeType = (recorder = globalThis.MediaRecorder) => {
  if (!recorder || typeof recorder.isTypeSupported !== "function") return null
  return RECORDING_MIME_CANDIDATES.find(type => recorder.isTypeSupported(type)) || null
}

// Capture is a strict subset of an established call: it needs both audio sources,
// a supported container, and a server generation to fence the upload against. It
// never gates the call itself — a false here means an unrecorded call, not a
// failed one.
export const recordingMayStart = ({
  enabled,
  mimeType,
  hasMicrophone,
  hasRemoteStream,
  generation,
  alreadyStarted,
}) =>
  Boolean(
    enabled &&
      mimeType &&
      hasMicrophone &&
      hasRemoteStream &&
      generation &&
      !alreadyStarted,
  )

export class CallRecorder {
  // `upload` and `finalize` are injected so the state machine can be tested
  // without a network, and so the hook keeps ownership of CSRF and keepalive.
  constructor({
    microphone,
    remoteStream,
    generation,
    timesliceMs,
    upload,
    finalize,
    onStateChange,
    audioContextClass = globalThis.AudioContext,
    recorderClass = globalThis.MediaRecorder,
    mimeType = supportedRecordingMimeType(recorderClass),
    now = () => (globalThis.performance ? globalThis.performance.now() : 0),
  }) {
    this.microphone = microphone
    this.remoteStream = remoteStream
    this.generation = generation
    this.timesliceMs = timesliceMs
    this.upload = upload
    this.finalize = finalize
    this.onStateChange = onStateChange || (() => {})
    this.audioContextClass = audioContextClass
    this.recorderClass = recorderClass
    this.mimeType = mimeType
    this.now = now

    this.state = "idle"
    this.sequence = 0
    this.queue = Promise.resolve()
    this.context = null
    this.destination = null
    this.recorder = null
    this.startedAt = null
  }

  start() {
    if (this.state !== "idle") return false

    try {
      this.context = new this.audioContextClass()
      this.destination = this.context.createMediaStreamDestination()

      // Two mono sources into one stereo stream: mic left, Sarah right.
      const merger = this.context.createChannelMerger(2)
      this.context.createMediaStreamSource(this.microphone).connect(merger, 0, 0)
      this.context.createMediaStreamSource(this.remoteStream).connect(merger, 0, 1)
      merger.connect(this.destination)

      this.recorder = new this.recorderClass(this.destination.stream, {mimeType: this.mimeType})
      this.recorder.ondataavailable = event => this.enqueue(event.data)
      this.recorder.onerror = () => this.fail()
      this.recorder.start(this.timesliceMs)

      this.startedAt = this.now()
      this.setState("recording")
      return true
    } catch (_error) {
      this.fail()
      return false
    }
  }

  // `stopping` still accepts slices: stopping a MediaRecorder flushes one last
  // `dataavailable`, and that tail is exactly the audio a call ends on.
  get accepting() {
    return this.state === "recording" || this.state === "stopping"
  }

  enqueue(blob) {
    if (!blob || !blob.size || !this.accepting) return

    // Slices are only media in order, so uploads are chained rather than raced.
    this.queue = this.queue.then(() => this.send(blob))
  }

  async send(blob) {
    if (!this.accepting) return

    const sequence = this.sequence + 1

    try {
      await this.upload({blob, sequence, generation: this.generation, mimeType: this.mimeType})
      this.sequence = sequence
    } catch (_error) {
      // A refusal is authoritative: a ceiling was reached, the generation is
      // stale, or the window closed. Retrying would only add load.
      this.fail()
    }
  }

  async stop({status = "complete"} = {}) {
    if (this.state !== "recording" && this.state !== "failing") return

    const outcome = this.state === "failing" ? "failed" : status
    this.setState("stopping")

    await this.stopRecorder()

    const durationMs =
      this.startedAt === null ? null : Math.max(0, Math.round(this.now() - this.startedAt))

    // Drained before the finalize, so the recording is closed against the last
    // slice that actually committed rather than ahead of it.
    await this.drainQueue()
    this.closeGraph()
    this.setState(outcome === "failed" ? "failed" : "complete")

    try {
      await this.finalize({status: outcome, generation: this.generation, durationMs})
    } catch (_error) {
      // The retention sweep closes a recording nobody finalized.
    }
  }

  // Resolves once the recorder has flushed, with a ceiling so a recorder that
  // never reports a stop cannot hang the call's shutdown path.
  stopRecorder() {
    return new Promise(resolve => {
      const recorder = this.recorder

      if (!recorder || recorder.state === "inactive") return resolve()

      let settled = false
      const done = () => {
        if (settled) return
        settled = true
        clearTimeout(ceiling)
        resolve()
      }

      const ceiling = setTimeout(done, 2_000)
      recorder.onstop = done

      try {
        recorder.stop()
      } catch (_error) {
        done()
      }
    })
  }

  // The chain can grow while it is being drained, because the flushed tail slice
  // arrives after `stop()`. Drain until it stops growing.
  async drainQueue() {
    let pending

    do {
      pending = this.queue
      await pending.catch(() => {})
    } while (pending !== this.queue)

    this.queue = Promise.resolve()
  }

  fail() {
    if (this.state === "recording") {
      this.setState("failing")
      // Detach first: a stopping recorder can still emit one more slice.
      if (this.recorder) this.recorder.ondataavailable = null
      this.stop({status: "failed"})
    }
  }

  closeGraph() {
    try {
      if (this.context && this.context.state !== "closed") this.context.close()
    } catch (_error) {
      // Closing an already-torn-down context is not an error worth surfacing.
    }

    this.context = null
    this.destination = null
    this.recorder = null
  }

  setState(state) {
    this.state = state
    this.onStateChange(state)
  }

  get capturing() {
    return this.state === "recording"
  }
}