lib/openagents/voice/recording.ex

58e6347eeb72 · 3 KB

defmodule OpenAgents.Voice.Recording do
  @moduledoc """
  One recorded audio artifact for one fenced voice generation.

  The bytes are browser-supplied evidence, never authority (`INVARIANTS.md`
  VOICE-012). `voice_transcript_items` remains the conversation record; this row
  says what audio a browser managed to upload for a call, and `status` says how
  complete that upload is:

    * `recording` — open, chunks may still arrive
    * `complete` — the browser finalized it
    * `truncated` — a ceiling was reached and later chunks were refused
    * `aborted` — the call ended without a finalize (tab closed, network lost)
    * `failed` — the browser reported that capture itself failed

  Every status except `failed` can still be listened to, because an ordered
  concatenation of the chunks that did arrive is playable audio. Status is
  honesty about completeness, not about availability.
  """

  use Ecto.Schema
  import Ecto.Changeset

  @statuses ~w(recording complete truncated aborted failed)
  @containers ~w(webm mp4 ogg)
  @codecs ~w(opus aac vorbis unknown)
  @channel_layouts ~w(mic_left_sarah_right mono_mix)

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  @timestamps_opts [type: :utc_datetime_usec]

  schema "voice_recordings" do
    belongs_to :voice_session, OpenAgents.Voice.Session
    field :generation, :integer
    field :status, :string
    field :container, :string
    field :codec, :string
    field :channel_layout, :string
    field :sealed, :boolean, default: false
    field :chunk_count, :integer, default: 0
    field :byte_size, :integer, default: 0
    field :client_duration_ms, :integer
    field :content_digest, :string
    field :started_at, :utc_datetime_usec
    field :completed_at, :utc_datetime_usec
    has_many :chunks, OpenAgents.Voice.RecordingChunk, foreign_key: :voice_recording_id
    timestamps()
  end

  @type t :: %__MODULE__{}

  @doc "Statuses whose stored chunks are worth offering to the operator."
  @spec playable_statuses() :: [String.t()]
  def playable_statuses, do: ~w(recording complete truncated aborted)

  def create_changeset(recording, attributes) do
    recording
    |> cast(attributes, [
      :voice_session_id,
      :generation,
      :status,
      :container,
      :codec,
      :channel_layout,
      :sealed,
      :started_at
    ])
    |> validate_required([
      :voice_session_id,
      :generation,
      :status,
      :container,
      :codec,
      :channel_layout,
      :started_at
    ])
    |> validate_inclusion(:status, @statuses)
    |> validate_inclusion(:container, @containers)
    |> validate_inclusion(:codec, @codecs)
    |> validate_inclusion(:channel_layout, @channel_layouts)
    |> validate_number(:generation, greater_than: 0)
    |> foreign_key_constraint(:voice_session_id)
    |> unique_constraint([:voice_session_id, :generation])
    |> check_constraint(:status, name: :voice_recordings_terminal_completion_check)
  end

  def append_changeset(recording, attributes) do
    recording
    |> cast(attributes, [:chunk_count, :byte_size])
    |> validate_required([:chunk_count, :byte_size])
    |> validate_number(:chunk_count, greater_than: recording.chunk_count)
    |> validate_number(:byte_size, greater_than_or_equal_to: recording.byte_size)
  end

  def terminal_changeset(recording, attributes) do
    recording
    |> cast(attributes, [:status, :client_duration_ms, :content_digest, :completed_at])
    |> validate_required([:status, :completed_at])
    |> validate_inclusion(:status, ~w(complete truncated aborted failed))
    |> validate_number(:client_duration_ms, greater_than_or_equal_to: 0)
    |> validate_length(:content_digest, is: 64)
    |> check_constraint(:status, name: :voice_recordings_terminal_completion_check)
  end
end