telephony

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 22, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Index

Constants

View Source
const (
	DecisionTypeVAD         = "vad"
	DecisionTypeCap         = "cap"
	DecisionTypeSTTDispatch = "stt_dispatch"
	DecisionTypeSTTResult   = "stt_result"

	DecisionKindSpeechStart  = "speech-start"
	DecisionKindSilence      = "silence"
	DecisionKindEndOfUtter   = "end-of-utterance"
	DecisionKindTurnEnd      = "turn-end"
	DecisionKindUtteranceCap = "utterance-cap"
	DecisionKindTurnCap      = "turn-cap"
	DecisionKindIdleTimeout  = "idle-timeout"

	DecisionParamSpeechThresh   = "SpeechThresh"
	DecisionParamSilenceThresh  = "SilenceThresh"
	DecisionParamEndSilence     = "EndSilenceMS"
	DecisionParamTurnEndSilence = "TurnEndSilenceMS"
	DecisionParamMaxUtterance   = "MaxUtteranceMS"
	DecisionParamMaxTurn        = "MaxTurnMS"
	DecisionParamMaxSilence     = "MaxSilenceMS"
)

Decision event types and the labels M1 emits. Kept as named constants so the producer (state.go) and any consumer read the same strings.

View Source
const DataPlaneBufferMS = 80

DataPlaneBufferMS is the target buffer depth in milliseconds for the data plane (audio frames and recognition results). This buffer absorbs jitter between frame-driven services (audio in, VAD, STT) and the message-driven control plane (LLM, TTS, Twilio out).

View Source
const HarnessVersion = "SOP-153-v1"

HarnessVersion tags every row this build of the harness produces (DoD: "rows carry provenance: recording id, VAD config, harness version").

View Source
const MarkEchoGraceMS = 500

MarkEchoGraceMS is the only tunable knob in the mark-echo timeout derivation: the margin added atop the farewell clip's own playout duration to give Twilio a chance to echo the mark back (SOP-125 Observable behavior #4).

View Source
const MaxSilenceMS = 15000

MaxSilenceMS is how long the session waits, hearing no VAD speech events, before starting call termination (SOP-125 Observable behavior #3).

The clock runs from the caller's most recent speech onset, not their first (see withSpeechReset), so this is dead air after they genuinely stop -- 20s of it read as the engine having failed to notice, and 10s left no room to pause and think mid-call.

View Source
const MaxTurnMS = 60000

MaxTurnMS caps a whole turn (SOP-161) -- the caller's entire stretch of speaking before the engine takes her turn, spanning any pauses in between. Unlike MaxUtteranceMS (which resets on every end-of-utterance), this timer runs from the turn's first speech onset until completeTurn, so a caller who breaks a long ramble into several sub-cap utterances is still bounded. 60s is a deliberately conservative start (tightening is a later tuning pass). Overridable per-process by AATOOLKIT_MAX_TURN_MS, and per-session by WithMaxTurnMS.

View Source
const MaxUtteranceMS = 45000

MaxUtteranceMS caps a single continuous utterance (SOP-156). While the caller speaks without reaching end-of-utterance, the utterance timer runs; on expiry the engine plays the forced-stop clip and hangs up. 45s is long enough for any genuine turn and short enough that a caller who holds the line open (a phone by a speaker) cannot run up an unbounded Twilio bill. Overridable per-process by AATOOLKIT_MAX_UTTERANCE_MS, and per-session by WithMaxUtteranceMS.

View Source
const MuLawFrameMS = 20

MuLawFrameMS is the duration of a single Twilio μ-law audio frame. Twilio sends 160-sample μ-law frames at 8 kHz = 20 ms per frame.

View Source
const SampleRateHz = 8000

SampleRateHz is the sample rate of μ-law audio from Twilio.

Variables

AllSources enumerates every InputSource, in declaration order. Used by TestTransitionTableIsTotal to iterate the full state space.

AllStates enumerates every SessionState, in declaration order. Used by TestTransitionTableIsTotal to iterate the full state space.

View Source
var FrameMS = MuLawFrameMS

FrameMS derives the frame duration in milliseconds from the static sample rate and the standard Twilio frame size. It is computed once at init to allow future flexibility if frame size changes.

Functions

func ComputeDepth

func ComputeDepth(bufferMS, frameMS int) int

ComputeDepth returns the buffer depth required for the given buffer duration (in milliseconds) and frame size (in milliseconds). depth = ceil(bufferMS / frameMS).

func EndSilenceWindows

func EndSilenceWindows() int

EndSilenceWindows is how many consecutive silence windows the VAD needs to see before it declares end-of-utterance, at the production defaults.

Test seam only. A test that needs to feed "enough silence for the caller to have finished" would otherwise hardcode the count -- EndSilenceMS divided by the window duration -- and that number rots silently the moment either value moves, failing as "end of utterance never fired" instead of pointing at the default that changed. Production code never needs this: vadMachine derives it from its own config.

func EventLogEnabled

func EventLogEnabled() bool

EventLogEnabled reports whether decision-event recording is switched on via AATOOLKIT_EVENT_LOG. Truthy values: 1/true/yes/on (case-insensitive). One definition shared by every wiring site (twilio live path, probeset replay).

func MarkEchoTimeout

func MarkEchoTimeout(clip []byte) time.Duration

MarkEchoTimeout derives how long to wait for Twilio's mark-echo after sending the farewell clip: the clip's own playout duration (μ-law is 1 byte/sample at SampleRateHz) plus MarkEchoGraceMS. Genuinely derived from clip length -- not a hardcoded constant -- so a different clip yields a different timeout (see TestMarkEchoTimeoutDerivedFromClip).

func NewSTTService

func NewSTTService(client *STTClient, input STTInput, output STTOutput) *sttService

NewSTTService returns an sttService reading from input and writing to output. The returned service has a cancel channel (CancelChan) that callers can use to mark requests as abandoned before transcription.

func StartSTTService

func StartSTTService(ctx context.Context, svc *sttService)

StartSTTService runs the sttService's main loop until ctx is cancelled. This is a seam to allow main.go to start the STT service without exporting the sttService.run method.

func TransitionHandlerDefined

func TransitionHandlerDefined(state SessionState, source InputSource) bool

TransitionHandlerDefined reports whether the transition table has an explicit (non-nil) entry for (state, source). Exported so TestTransitionTableIsTotal can assert totality without reaching into package-private table internals.

func TurnEndSilenceWindows

func TurnEndSilenceWindows() int

TurnEndSilenceWindows is how many consecutive silence windows the VAD needs to see, since the start of the current trailing-silence run, before it declares turn-end, at the production defaults.

Test seam only, same rationale as EndSilenceWindows: a test that must feed "enough silence to end the turn" derives the count from config rather than hardcoding it.

func ValidateVAD

func ValidateVAD(factory func() (VADDetector, error)) error

ValidateVAD is the engine's startup self-test for the VAD pipeline: it constructs a detector from the given factory and runs one real inference, failing hard (a non-nil error) if the detector can't be built or produces an implausible result. A consumer calls this once before accepting calls, passing the same factory it wires via WithVADFactory (e.g. NewVADClient(url).Detector()) — since SOP-147 the detector is the out-of- process sidecar, so the self-test also confirms the sidecar answers.

Types

type BufferedChan

type BufferedChan[T any] struct {
	// contains filtered or unexported fields
}

BufferedChan[T] is a concrete service channel implementation that wraps a buffered Go channel. The buffer depth is derived from DataPlaneBufferMS and FrameMS: depth = ceil(DataPlaneBufferMS / FrameMS).

func NewBufferedChan

func NewBufferedChan[T any](depth int) *BufferedChan[T]

NewBufferedChan creates a new BufferedChan with the specified depth.

func (*BufferedChan[T]) Channel

func (bc *BufferedChan[T]) Channel() <-chan T

Channel returns the underlying chan T (read-only view for receivers). Callers can range over this or use it as the receive side of select.

func (*BufferedChan[T]) Recv

func (bc *BufferedChan[T]) Recv(ctx context.Context) (T, error)

Recv receives a value from the channel, respecting the context deadline. If the context is cancelled before a value is available, returns the context error and the zero value of T.

func (*BufferedChan[T]) Send

func (bc *BufferedChan[T]) Send(ctx context.Context, val T) error

Send sends a value into the channel, respecting the context deadline. If the context is cancelled before the send completes, returns the context error.

type ControlEvent

type ControlEvent struct {
	Kind     string
	MarkName string
	CallSID  string
}

ControlEvent is a Twilio control-plane signal (e.g. "stop") routed to a Session's controlIn field, decoupled from twilio.Frame to avoid an import cycle (internal/telephony/twilio already imports internal/telephony). Twilio-side adapters translate twilio.Frame into ControlEvent.

type ControlOutKind

type ControlOutKind string

ControlOutKind identifies which Twilio control-plane message a ControlOutMessage carries: EncodeMark or EncodeClear (SOP-125). EncodeStop is never sent here -- it's a client-side function (twilio/stream.go), not part of the server's outbound vocabulary; the server ends a call by closing the WebSocket instead.

const (
	ControlOutMark  ControlOutKind = "mark"
	ControlOutClear ControlOutKind = "clear"
)

type ControlOutMessage

type ControlOutMessage struct {
	Kind     ControlOutKind
	MarkName string
}

ControlOutMessage is the send-side counterpart to ControlEvent: a Twilio control-plane message a Session writes out via TwilioControlPlaneOutput.

type DatasetRow

type DatasetRow struct {
	RecordingID    string   `json:"recording_id"`
	Utterances     []string `json:"utterances"`
	Label          RowLabel `json:"label"`
	EndSilenceMS   int      `json:"end_silence_ms"`
	HarnessVersion string   `json:"harness_version"`
}

DatasetRow is one cumulative-prefix row of a replayed recording's FullPass transcripts, labelled structurally rather than by hand.

func RowsFromUtterances

func RowsFromUtterances(recordingID string, recLabel RecordingLabel, utterances []string, endSilenceMS int) []DatasetRow

RowsFromUtterances builds one cumulative-prefix DatasetRow per utterance in utterances (u1, u1+u2, ... u1..un), each labelled structurally from recLabel: every prefix short of the full recording is `incomplete` by construction -- the caller demonstrably kept talking past it, which is not a judgment call. The final, full-recording prefix is `complete`, unless recLabel is `truncated`, in which case the recording never reached a confirmed end and every row -- including the terminal one -- is `incomplete` (ticket SOP-153 "Why": labels are structural, not hand-written; re-running at a different --end-silence-ms produces a different, equally correct set because the utterances it structures over change with the VAD config).

type DecisionEvent

type DecisionEvent struct {
	Seq          int     `json:"seq"`
	AudioMS      int     `json:"audio_ms"`
	Type         string  `json:"type"`
	Kind         string  `json:"kind,omitempty"`
	Param        string  `json:"param,omitempty"`
	ParamValue   any     `json:"param_value,omitempty"`
	Prob         float32 `json:"prob,omitempty"`
	SilenceCount int     `json:"silence_count,omitempty"`
	RequestID    int     `json:"request_id,omitempty"`
	Effect       string  `json:"effect,omitempty"`

	// STT round-trip fields (SOP-167). Text is the delivered transcript;
	// LatencyMS is result_time - dispatch_time from the injected clock;
	// AudioBytes is the dispatched mu-law buffer length; STTDurSec is whisper's
	// own reported audio duration. All omitempty so non-STT events stay compact.
	Text       string  `json:"text,omitempty"`
	LatencyMS  int     `json:"latency_ms,omitempty"`
	AudioBytes int     `json:"audio_bytes,omitempty"`
	STTDurSec  float64 `json:"stt_audio_sec,omitempty"`
}

DecisionEvent is one recorded decision. The shape is deliberately flat and JSON-friendly (one object per JSONL line); fields not relevant to a given event type are omitted. M1 populates only the VAD end-of-utterance shape; later milestones add further event types (STT, caps) and any fields they need.

AudioMS is a position in the INPUT audio (derived from the monotonic VAD-window clock), never wall-clock, so the record is stable under replay.

type DecisionRecorder

type DecisionRecorder interface {
	Record(ev DecisionEvent)
	Close() error
}

DecisionRecorder receives one structured DecisionEvent per parameterized choice the voice-input path makes (M1: end-of-utterance only). It exists so the parameters we set for dealing with voice input can be evaluated after the fact: each event ties a parameter and its value to a position in the input audio and the effect the choice had.

Implementations must return promptly from Record: it is called synchronously from the session's single sequencer loop and must not block it (same contract as TurnSink). Close flushes any buffered state and is idempotent.

type FileDecisionRecorder

type FileDecisionRecorder struct {
	// contains filtered or unexported fields
}

FileDecisionRecorder buffers events in memory, feeds each one live to an io.Writer as it arrives, and on Close flushes the buffer to <streamSID>.events.jsonl plus a <streamSID>.events.header.json in dir. It mirrors the audio tap's lifecycle (per-stream, buffer, write-on-close) and is written to the same directory so events sit beside the audio they describe.

func NewFileDecisionRecorder

func NewFileDecisionRecorder(dir, streamSID, callSID, label string, cfg VADConfig, live io.Writer) *FileDecisionRecorder

NewFileDecisionRecorder builds a recorder that writes into dir. A nil return (dir == "") lets the caller decide, per the tap convention, to wire nothing rather than a disabled object. live is where each event is echoed as it arrives; nil defaults to os.Stderr.

func (*FileDecisionRecorder) Close

func (r *FileDecisionRecorder) Close() error

Close writes the header and the buffered events, then marks the recorder closed so a second Close (or a late Record) is a no-op. Both files are keyed by streamSID to line up with the tap's <streamSID>.in.ulaw / .json naming.

func (*FileDecisionRecorder) Record

func (r *FileDecisionRecorder) Record(ev DecisionEvent)

type InputSource

type InputSource int

InputSource identifies which channel or timer fired to produce a transitionEvent. It is the second axis of the (state, source) transition table.

const (
	SourceTwilioData InputSource = iota
	SourceTwilioControl
	SourceVADEvent
	SourceSTTResult
	SourceIdleTimer
	SourceMarkEchoTimer
	SourceUtteranceTimer
	SourceSimTurnTimer
	SourceTurnTimer
)

func (InputSource) String

func (s InputSource) String() string

type Message

type Message struct {
	Text      string
	Transport TransportType
	SessionID string
}

Message is a typed turn at the policy seam: the compiled harness produces one per user utterance; the interpreted policy reads it and adapts response style to the transport.

type ProdTurnSink

type ProdTurnSink struct {
	CallSID string
	// contains filtered or unexported fields
}

ProdTurnSink is the production TurnSink: it logs turn completions with structured logging (turn number, text, timestamp). Satisfies the TurnSink interface defined in vad.go.

func (*ProdTurnSink) OnEndOfUtterance

func (p *ProdTurnSink) OnEndOfUtterance()

func (*ProdTurnSink) OnSpeechStart

func (p *ProdTurnSink) OnSpeechStart()

func (*ProdTurnSink) OnTurnComplete

func (p *ProdTurnSink) OnTurnComplete(text string, trigger TurnTrigger)

type RecordingLabel

type RecordingLabel string

RecordingLabel is the vocabulary a captured recording is tagged with at capture time (AATOOLKIT_TAP_LABEL, twilio/tap.go's sidecar "label" field), read back here to derive each recording's dataset rows structurally instead of by hand.

const (
	LabelCompleteTurn RecordingLabel = "complete-turn"
	LabelTruncated    RecordingLabel = "truncated"
	LabelGreeting     RecordingLabel = "greeting"
	LabelHowAreYou    RecordingLabel = "how-are-you"
	LabelDoneEnding   RecordingLabel = "done-ending"
)

type ReplayResult

type ReplayResult struct {
	Text string `json:"text"`
}

ReplayResult is one utterance's FullPass transcript from a replay run (ticket SOP-153 Observable behavior #1: "emits the ordered FullPass transcripts as JSON" -- one per utterance, the same granularity `build` structures its cumulative-prefix rows over, not one per fused turn).

func Replay

func Replay(ctx context.Context, callSID string, audioStream io.Reader, sttClient *STTClient, opts ...SessionOption) ([]ReplayResult, error)

Replay drives audioStream (typically a captured .ulaw's bytes) through a production Session -- the same VAD+STT path a live call runs, not a copy -- and returns the ordered FullPass turn transcripts.

sttClient is the same STTClient production wires (internal/telephony/stt.go); callers pass a real one (NewSTTClient(sttBaseURL)) to drive a genuine whisper sidecar, or one pointed at an httptest server for deterministic tests -- either way it is the identical STTClient.Transcribe code path, never a stand-in.

opts overrides Session construction after Replay's own defaults (turn sink, data/control/STT wiring, StopwordPolicy) are applied, so a caller can inject e.g. WithVADConfig to change --end-silence-ms, or WithVADFactory in tests that don't need the real Silero model.

Replay is deterministic: for fixed audioStream bytes, a fixed sttClient response set, and fixed opts, two calls produce byte-identical results, every run -- no wall-clock sleep gates any part of this function.

type RowLabel

type RowLabel string

RowLabel is a dataset row's structurally-derived completeness label.

const (
	RowIncomplete RowLabel = "incomplete"
	RowComplete   RowLabel = "complete"
)

type STTClient

type STTClient struct {
	// contains filtered or unexported fields
}

STTClient transcribes μ-law telephony audio via an OpenAI-shaped /v1/audio/transcriptions endpoint (the whisper sidecar). It holds one keep-alive http.Client so chunked clips reuse a single connection.

func NewSTTClient

func NewSTTClient(baseURL string) *STTClient

NewSTTClient returns a client posting to baseURL (e.g. http://127.0.0.1:7789).

func (*STTClient) Transcribe

func (c *STTClient) Transcribe(ctx context.Context, id string, mulawBytes []byte) (STTResult, error)

Transcribe returns the full verbose_json decode of a μ-law clip. Clips longer than 30s are split into <=30s chunks transcribed in order; their text is joined and the first chunk's Language/Duration/Segments are kept (whisper's chunk-level detail beyond joined text is not defined across a multi-chunk clip -- callers needing per-chunk segments should not send clips over maxClipBytes).

type STTInput

type STTInput = ServiceInput[STTRequest]

STTInput is the receive side of the channel sttService reads STTRequests from (the SOP-116 ServiceInput[T] pattern).

type STTOutput

type STTOutput = ServiceOutput[STTResult]

STTOutput is the send side of the channel sttService writes STTResults to (the SOP-116 ServiceOutput[T] pattern).

type STTPassKind

type STTPassKind string

STTPassKind identifies which recognition pass a request or result belongs to. FullPass is the sole remaining pass: a transcription over the full utterance.

const (
	FullPass STTPassKind = "full"
)

type STTRequest

type STTRequest struct {
	SessionID string
	RequestID int
	Kind      STTPassKind
	Audio     []byte
}

STTRequest is the unit of work sent to sttService: a μ-law clip tagged with the session/request identity it must be correlated back to.

type STTResult

type STTResult struct {
	SessionID string
	RequestID int
	Kind      STTPassKind
	Text      string
	Language  string
	Duration  float64
	Segments  []STTSegment
}

STTResult is the lossless whisper verbose_json record for one STTRequest, tagged with the same correlation identity the request carried (Charter R9).

type STTRouter

type STTRouter struct {
	// contains filtered or unexported fields
}

STTRouter fans the single sttService result stream out to per-session channels, keyed by SessionID. It exists because sttOut is process-global (one sttService for all calls): sessions receiving from it directly race each other, so a result can be delivered to -- and dropped by -- the wrong session, or sit orphaned in the shared buffer until an unrelated later call drains it (both observed live; see handleSTTResult's SessionID guard, which with the router in place becomes a true invariant check rather than load-bearing routing).

Lifecycle: Register a session's channel before its call starts consuming results, Deregister after the call ends. Results for a SessionID with no registered route are logged and dropped -- with per-session routing that is the correct fate for a result whose call has already hung up.

func NewSTTRouter

func NewSTTRouter(results STTOutput) *STTRouter

NewSTTRouter returns a router that reads from results (the channel sttService writes to). Call Run to start dispatching.

func (*STTRouter) Deregister

func (r *STTRouter) Deregister(sessionID string, route STTOutput)

Deregister removes sessionID's route, but only if route is still the one registered under it. Results arriving after this are logged and dropped. Deregistering an unknown SessionID, or one whose route has since been replaced, is a no-op.

The identity check is what makes duplicate SessionIDs merely bad rather than silently destructive. Two calls sharing a CallSID means the second Register replaces the first's route; when the first call then ends, a key-only delete would remove the *second*, live call's route, and every result for it from then on would be dropped as "unregistered" -- a call going deaf for the rest of its life because an unrelated one hung up. Comparing identity means an ending call can only ever remove its own route.

func (*STTRouter) Register

func (r *STTRouter) Register(sessionID string) STTOutput

Register creates, stores, and returns the per-session result channel for sessionID -- the value to wire into the session via WithSTTOutput. Registering a SessionID that is already registered replaces the old route (logged loudly: it means two live calls share a CallSID, which Twilio guarantees against -- but twilio-cli is a stand-in that mints its own).

The returned route is also the handle to Deregister with: see there.

func (*STTRouter) Run

func (r *STTRouter) Run(ctx context.Context)

Run reads results until ctx is cancelled or the results channel errors, dispatching each to its session's registered route. The send is non-blocking: a full route (a session that stopped draining) drops the result with a loud log rather than stalling every other session's delivery.

type STTSegment

type STTSegment struct {
	Text         string
	Start        float64
	End          float64
	AvgLogprob   float64
	NoSpeechProb float64
}

STTSegment is one segment of a whisper verbose_json transcription: a timed span of text with the model's confidence for that span.

type ScoreReport

type ScoreReport struct {
	PromptFile   string                  `json:"prompt_file"`
	EndSilenceMS int                     `json:"end_silence_ms"`
	RowCount     int                     `json:"row_count"`
	Outcomes     map[VerifierOutcome]int `json:"outcomes"`
}

ScoreReport is score's output: which prompt ran, at which VAD config, over how many rows, and how many rows landed in each of the four outcomes. Deliberately carries no accuracy field (DoD: "score reports the four outcomes, not accuracy").

func Score

func Score(ctx context.Context, promptFile string, rows []DatasetRow, endSilenceMS int, verify Verifier) (ScoreReport, error)

Score runs verify over every row and tallies the four named outcomes.

type ServiceInput

type ServiceInput[T any] interface {
	Channel() <-chan T
	Send(ctx context.Context, val T) error
	Recv(ctx context.Context) (T, error)
}

ServiceInput[T] is the generic interface for a service that accepts typed input. It wraps a channel and provides context-aware send/recv.

type ServiceOutput

type ServiceOutput[T any] interface {
	Channel() <-chan T
	Send(ctx context.Context, val T) error
	Recv(ctx context.Context) (T, error)
}

ServiceOutput[T] is the generic interface for a service that produces typed output. It wraps a channel and provides context-aware send/recv.

type Session

type Session struct {
	CallSID string
	History []Message
	// contains filtered or unexported fields
}

Session is the per-call coordination point. It owns the service-interface inputs that drive the pipeline, the call's identity and conversation history, and the cancel func that tears the call down. A single sequencer goroutine per session drives the entire pipeline via one select loop (see run) -- there is no separate VAD-event dispatcher goroutine; turn-taking dispatch happens inline from the transition table.

A Session is driven by one owning goroutine: Start and Close are not safe to call concurrently with each other.

func NewSession

func NewSession(ctx context.Context, callSID string, opts ...SessionOption) *Session

NewSession builds a session for callSID with a cancel derived from ctx. It does not start the sequencer or the VAD goroutine — call Start for that.

func (*Session) Close

func (s *Session) Close()

Close cancels the session and, if the sequencer was started, waits for its goroutines to exit before releasing the VAD service. Safe to call on a session that was never started.

func (*Session) Closed

func (s *Session) Closed() <-chan struct{}

Closed returns a channel that closes when the session transitions to StateClosed. Safe to call from any goroutine.

func (*Session) Start

func (s *Session) Start() error

Start spawns the sequencer and the VAD-forward goroutine for this session. It is idempotent: calling it again after the first success is a no-op returning nil.

Start constructs the session's vadDetector via its vadFactory (required — wired with WithVADFactory; there is no in-process default since SOP-147), failing hard: a missing factory or a factory error is returned and nothing is started — no goroutines are spawned, so a caller that gets an error can retry or abandon the session cleanly.

func (*Session) State

func (s *Session) State() SessionState

State returns the session's current SessionState. Safe to call from any goroutine, including concurrently with the running sequencer.

func (*Session) TurnActive

func (s *Session) TurnActive() bool

TurnActive reports whether a turn is currently in progress. Safe to call from any goroutine: turnActive is written only from the sequencer goroutine, always followed later in the same event's handling by setState's Lock/Unlock (called unconditionally after every transition, run()), so acquiring stateMu here -- the same mutex State() uses -- establishes happens-before for turnActive's most recent write too, the same way it does for state itself.

type SessionOption

type SessionOption func(*Session)

SessionOption configures optional Session behavior at construction time.

func WithClock

func WithClock(after func(time.Duration) <-chan time.Time) SessionOption

WithClock replaces the passage of time for this session's timers (idle, utterance, markEcho) with after. nil, the default, means the wall clock.

Test seam only, and the reason it exists: a timer-driven transition can otherwise only be observed by sleeping past its deadline. Such a test asserts on the scheduler rather than the code, and -- worse -- silently stops proving anything the moment the deadline it was meant to outlast grows past the sleep. With a fake clock the test fires the timer itself and the assertion is exact.

func WithCloseFunc

func WithCloseFunc(f func()) SessionOption

WithCloseFunc overrides how the session tears down its underlying transport once Closed via the termination flow. Production wires this to the Twilio WebSocket's close; tests inject a fake to observe it was called without a real connection.

func WithDecisionClock

func WithDecisionClock(now func() time.Time) SessionOption

WithDecisionClock replaces the monotonic clock the decision recorder reads to time STT round-trip latency (SOP-167) with now. nil, the default, means the wall clock (time.Now). Distinct from WithClock: that seam supplies relative durations for timers; this one supplies absolute instants for latency. Test seam -- a fake now advanced by a known delta makes the recorded latency exact.

func WithDecisionRecorder

func WithDecisionRecorder(r DecisionRecorder) SessionOption

WithDecisionRecorder wires the DecisionRecorder that receives one DecisionEvent per parameterized voice-input choice (M1: end-of-utterance). Unset, a session uses a no-op recorder (NewSession default) and records nothing. The session owns the recorder's lifecycle: Close flushes it.

func WithFileDecisionRecorderFromEnv

func WithFileDecisionRecorderFromEnv(dir, streamSID, callSID, label string, cfg VADConfig, live io.Writer) SessionOption

WithFileDecisionRecorderFromEnv returns a SessionOption that wires a FileDecisionRecorder writing into dir when AATOOLKIT_EVENT_LOG is on and dir is non-empty; otherwise it is a no-op option. It folds the enable-flag gate and the nil-recorder check that the live (twilio) and replay (probeset) wiring sites would otherwise each repeat.

func WithMaxSilenceMS

func WithMaxSilenceMS(ms int) SessionOption

WithMaxSilenceMS overrides MaxSilenceMS for this session. Test seam only: MaxSilenceMS's real multi-second default is impractical to wait out in a test.

func WithMaxTurnMS

func WithMaxTurnMS(ms int) SessionOption

WithMaxTurnMS overrides MaxTurnMS for this session (SOP-161). Test seam and the live-testing knob behind AATOOLKIT_MAX_TURN_MS.

func WithMaxUtteranceMS

func WithMaxUtteranceMS(ms int) SessionOption

WithMaxUtteranceMS overrides MaxUtteranceMS for this session (SOP-156). Test seam and the live-testing knob behind AATOOLKIT_MAX_UTTERANCE_MS.

func WithSTTInput

func WithSTTInput(in STTInput) SessionOption

WithSTTInput wires in the STT service's request input as the destination for this session's dispatched full-pass STTRequests (SOP-124).

func WithSTTOutput

func WithSTTOutput(out STTOutput) SessionOption

WithSTTOutput wires in the STT service's result output as this session's source of recognition results.

func WithSimTurnMS

func WithSimTurnMS(ms int) SessionOption

WithSimTurnMS enables sim-turn bed playback (SOP-157) for the configured duration (ms). <= 0 disables it (production default).

func WithTranscriptAgentLabel

func WithTranscriptAgentLabel(label string) SessionOption

WithTranscriptAgentLabel sets the response role label the transcript summary prints (SOP-168). Empty or unset falls back to the generic "agent"; a consumer injects its own product name here so the engine never embeds it.

func WithTranscriptOutput

func WithTranscriptOutput(dir, sid string, live io.Writer) SessionOption

WithTranscriptOutput enables the end-of-call conversation transcript summary (SOP-168). At Close the session renders each turn's utterances bracketed and: prints the summary to live (nil = no print), and writes <sid>.transcript.txt into dir (dir == "" = no file). Live and replay wiring pass the same dir/sid as the audio tap / decision record so the transcript sits beside them.

func WithTurnEndPolicy

func WithTurnEndPolicy(p TurnEndPolicy) SessionOption

WithTurnEndPolicy injects the policy that decides whether a FullPass transcript closes the current turn. Without a policy, turns are only flushed on call end.

func WithTurnSink

func WithTurnSink(sink TurnSink) SessionOption

WithTurnSink overrides the TurnSink that receives this session's VAD boundary events. Start defaults to a logging TurnSink when none is given.

func WithTwilioControlInput

func WithTwilioControlInput(in TwilioControlPlaneInput) SessionOption

WithTwilioControlInput wires in the Twilio control-plane demux output (SOP-120) as this session's source of control-plane signals.

func WithTwilioControlOutput

func WithTwilioControlOutput(out TwilioControlPlaneOutput) SessionOption

WithTwilioControlOutput wires in the destination for the outbound mark sent alongside the farewell clip (SOP-125).

func WithTwilioDataInput

func WithTwilioDataInput(in TwilioDataPlaneInput) SessionOption

WithTwilioDataInput wires in the Twilio data-plane demux output (SOP-120) as this session's source of inbound media payloads.

func WithTwilioDataOutput

func WithTwilioDataOutput(out TwilioDataPlaneOutput) SessionOption

WithTwilioDataOutput wires in the destination for the farewell clip's outbound audio frames (SOP-125).

func WithVADConfig

func WithVADConfig(cfg VADConfig) SessionOption

WithVADConfig overrides this session's VAD config (e.g. EndSilenceMS). Unset (zero-value) fields are filled from defaultVADConfig() by Session.Start via withDefaults, same as an entirely omitted config.

Production never calls this -- every live call runs defaultVADConfig(), which is why vad.go's DefaultVADConfig doc says the config is process-wide, not per-session. This option exists for SOP-153's replay harness: `probeset replay --end-silence-ms N` needs to drive one Session at a caller-chosen threshold per invocation without mutating the package-wide default (which would race concurrent replays and leak into any live call sharing the process).

func WithVADEventObserver

func WithVADEventObserver(f func(ev VADEvent, emitted bool)) SessionOption

WithVADEventObserver registers f to be called once per inference window this session's VAD processes, settling that window's fate (see newVADService's onWindow param in vad.go): emitted=false fires as soon as the window is known to have produced no VADEvent; emitted=true and the real event fire only once that event is guaranteed-delivered onto this session's VAD output channel, i.e. once run()'s select loop is guaranteed to see it, not merely once it was computed.

Production never calls this either. It exists alongside WithVADConfig for SOP-153's replay harness: Replay needs to know every window it fed has been fully accounted for -- including any VADEndOfUtterance it produced having actually reached the sequencer -- before it can safely conclude no more STT dispatches are coming and end the call. A real completion signal in place of a fixed sleep.

func WithVADFactory

func WithVADFactory(f func() (VADDetector, error)) SessionOption

WithVADFactory overrides how Start constructs the vadDetector used by this session's VAD goroutine. Production code doesn't need this — Start defaults to NewSileroDetector — but tests use it to inject a fake detector without touching the real ONNX model.

type SessionState

type SessionState int

SessionState is a state in the session's total (state, source) transition table (charter: single select loop, SOP-123). AwaitingFullResult, Terminating, and AwaitingMarkEcho exist in the enum ahead of the logic that drives them -- their transition handlers are stubs that log "not yet implemented" (SOP-115/G,H own that logic; out of scope here).

const (
	StateIdle SessionState = iota
	StateListening
	StateAwaitingFullResult
	StateSpeaking
	StateTerminating
	StateAwaitingMarkEcho
	StateClosed
)

func (SessionState) String

func (s SessionState) String() string

type StopwordPolicy

type StopwordPolicy struct{}

StopwordPolicy closes the turn when the normalized transcript equals exactly "done". Normalization: lowercase, trim whitespace, strip trailing punctuation (.!?,). Matching is exact after normalization.

func (StopwordPolicy) IsEndOfTurn

func (StopwordPolicy) IsEndOfTurn(transcript string) bool

type TimerCompletion

type TimerCompletion struct {
	Name     string
	TimerID  int
	Duration time.Duration
}

TimerCompletion represents the result of a completed timer.

type TimerFacility

type TimerFacility struct {
	// contains filtered or unexported fields
}

TimerFacility manages multiple named timers with generation counters. It is NOT thread-safe for direct manipulation, but uses channels for safe coordination between goroutines.

func NewTimerFacility

func NewTimerFacility(ctx context.Context) *TimerFacility

NewTimerFacility creates a new timer facility with the given context. When the context is cancelled, all armed timers' goroutines will exit.

func NewTimerFacilityWithClock

func NewTimerFacilityWithClock(ctx context.Context, after func(time.Duration) <-chan time.Time) *TimerFacility

NewTimerFacilityWithClock builds a facility whose timers wait via after instead of the wall clock. Production passes time.After (NewTimerFacility); tests pass a fake so a timer fires exactly when the test says it does, rather than when the scheduler gets round to it.

func (*TimerFacility) Arm

func (tf *TimerFacility) Arm(ctx context.Context, name string, duration time.Duration) int

Arm starts a timer with the given name and duration. It returns a generation counter (timerId). If a timer with this name is already armed, it cancels the previous one first. Returns -1 if the facility's context has been cancelled.

func (*TimerFacility) Cancel

func (tf *TimerFacility) Cancel(name string)

Cancel cancels the timer with the given name. If the timer's completion is already pending, this prevents it from being sent. Cancelling a non-existent timer is a no-op. Post-teardown calls are silently ignored.

func (*TimerFacility) Completions

func (tf *TimerFacility) Completions() <-chan TimerCompletion

Completions returns a read-only channel that receives TimerCompletion events when armed timers fire.

func (*TimerFacility) IsCurrent

func (tf *TimerFacility) IsCurrent(completion TimerCompletion) bool

IsCurrent checks whether the given completion's TimerID is the current generation for its name. This allows consumers to distinguish stale fires from current ones. Returns false if the facility's context is cancelled.

type TransportType

type TransportType string

TransportType identifies the channel over which a conversation turn arrived. It is an open string enum — future transport additions do not require recompilation.

const (
	TransportVoice    TransportType = "voice"
	TransportSMS      TransportType = "sms"
	TransportOutbound TransportType = "outbound"
)

type TurnEndPolicy

type TurnEndPolicy interface {
	IsEndOfTurn(transcript string) bool
}

TurnEndPolicy decides whether a FullPass transcript closes the current turn. If IsEndOfTurn returns true the accumulated buffer is flushed (excluding the triggering utterance) and the stopword is consumed. A session with no policy injected still flushes on call end.

type TurnSink

type TurnSink interface {
	OnSpeechStart()
	OnEndOfUtterance()
	OnTurnComplete(text string, trigger TurnTrigger)
}

OnTurnComplete delivers the fused text of a completed turn (SOP-150): multiple utterances joined with a single space, each part trimmed, plus the trigger that ended the turn (SOP-162). Implementations must return promptly: they are called synchronously from the session's single transition-table dispatch loop (state.go's handleSpeechOnset/ dispatchFullPass) and must not block it.

type TurnTrigger

type TurnTrigger string

TurnSink receives turn-taking boundaries dispatched from a session's VAD events. OnSpeechStart fires when the caller starts talking (SOP-95 wires this as a no-op stub — barge-in behavior beyond that is out of scope); OnEndOfUtterance fires when the caller's utterance is judged complete. TurnTrigger names which of completeTurn's seven call sites (state.go, six distinct trigger values — silence-turn-end covers two of the seven: the immediate and the deferred-while-a-pass-was-in-flight paths) ended a turn (SOP-162 DoD: "trigger type" in every TurnSink.OnTurnComplete log line).

const (
	TriggerStopword       TurnTrigger = "stopword"
	TriggerSilenceTurnEnd TurnTrigger = "silence-turn-end"
	TriggerIdleTimeout    TurnTrigger = "idle-timeout"
	TriggerUtteranceCap   TurnTrigger = "utterance-cap"
	TriggerTurnCap        TurnTrigger = "turn-cap"
	TriggerCallEnd        TurnTrigger = "call-end"
)

type TwilioControlPlaneInput

type TwilioControlPlaneInput = ServiceInput[ControlEvent]

TwilioControlPlaneInput is the receive side of the channel a Session reads Twilio control-plane signals from (SOP-120's demux control plane, adapted).

type TwilioControlPlaneOutput

type TwilioControlPlaneOutput = ServiceOutput[ControlOutMessage]

TwilioControlPlaneOutput is the send side of the channel a Session writes outbound control-plane messages to (mark/clear). The concrete implementation (internal/telephony/twilio/output.go) encodes each message via EncodeMark/EncodeClear and writes it to the WebSocket.

type TwilioDataPlaneInput

type TwilioDataPlaneInput = ServiceInput[[]byte]

TwilioDataPlaneInput is the receive side of the channel a Session reads inbound media payloads from (SOP-120's demux data plane, adapted).

type TwilioDataPlaneOutput

type TwilioDataPlaneOutput = ServiceOutput[[]byte]

TwilioDataPlaneOutput is the send side of the channel a Session writes outbound media frames to (SOP-116 ServiceOutput pattern). The concrete implementation (internal/telephony/twilio/output.go) encodes each payload via EncodeMedia and writes it to the WebSocket; this package only sees the generic interface to avoid an import cycle (twilio already imports telephony).

type VADClient

type VADClient struct {
	// contains filtered or unexported fields
}

VADClient posts inference windows to the VAD sidecar's octet-stream endpoint (scripts/vad_server.py). It holds one keep-alive http.Client so a call's stream of windows reuses a single connection, mirroring STTClient.

func NewVADClient

func NewVADClient(baseURL string) *VADClient

NewVADClient returns a client posting to baseURL (e.g. http://127.0.0.1:7790).

func (*VADClient) Detector

func (c *VADClient) Detector() func() (VADDetector, error)

Detector returns a vadFactory (the WithVADFactory shape) that builds a fresh httpSileroDetector with zeroed state per call — one per session, so no session's recurrent state leaks into another's.

type VADConfig

type VADConfig = vadConfig

VADConfig is an exported alias for vadConfig, so the audio tap (SOP-152, in package twilio) can record the config this process runs. Same need and same shape as the VADDetector alias above: name the type outward without handing out construction. An alias rather than a parallel struct keeps one definition of the fields.

func DefaultVADConfig

func DefaultVADConfig() VADConfig

DefaultVADConfig returns the VAD config every live call runs. It is process-wide for production traffic: no live call passes WithVADConfig, so vadCfg is filled from defaultVADConfig() via withDefaults() for every real session. The tap records it so a later replay (SOP-153) knows which thresholds produced a recording's live log -- defaultVADConfig() is a moving target (f5cad49 took EndSilenceMS 700 -> 1050) and the value cannot be recovered after the fact. SOP-153's replay harness is the one caller that does override per-session, via WithVADConfig (session.go), so it can drive `--end-silence-ms` without touching this package-wide default.

type VADDetector

type VADDetector = vadDetector

VADDetector is an exported alias for vadDetector — identical type, just spellable from outside the package. WithVADFactory's parameter needs an exported name so external test packages (telephony_test) can supply a custom factory without this package exposing detector construction.

type VADEvent

type VADEvent struct {
	Kind         VADKind
	Prob         float32 // detector probability that produced this event
	VoicedCount  int     // voiced windows accumulated since speech-start
	SilenceCount int     // consecutive sub-SilenceThresh windows since speech
	WindowIndex  int     // monotonic per-utterance window counter (resets at each speech onset)
	// StreamWindowIndex is a monotonic, never-reset window counter over the
	// whole stream (0 at the first window, +1 per window processed). Unlike
	// WindowIndex it does not reset at speech onset, so AudioMS =
	// StreamWindowIndex * windowMS gives a stable position in the input audio
	// for a DecisionEvent -- see decision.go.
	StreamWindowIndex int
	SessionID         string // set by the vadService wrapper at construction time
}

VADEvent is a voice-activity boundary emitted on a vadService's VADOutput. It is a lossless record of the vadMachine's full state at the moment of emission (charter R9) and carries SessionID on every result (charter R10).

type VADInput

type VADInput = ServiceOutput[[]byte]

VADInput is the frames-in plane of a vadService (SOP-116 pattern): callers Send raw μ-law frames for the wrapped vadMachine to consume.

type VADKind

type VADKind string

VADKind is the kind of voice-activity event emitted by the VAD goroutine.

const (
	VADSpeech         VADKind = "speech"
	VADSilence        VADKind = "silence"
	VADEndOfUtterance VADKind = "end-of-utterance"
	VADTurnEnd        VADKind = "turn-end"
)

type VADOutput

type VADOutput = ServiceInput[VADEvent]

VADOutput is the events-out plane of a vadService (SOP-116 pattern): callers Recv the VADEvents the wrapped vadMachine emits.

type Verifier

type Verifier func(ctx context.Context, promptFile string, row DatasetRow) (VerifierOutcome, error)

Verifier runs promptFile's prompt over one dataset row and reports which of the four outcomes it produced. cmd/probeset's real score wiring supplies one backed by an actual model call; tests supply a fixed fake, so Score's own aggregation logic is exercised independent of any live prompt call.

type VerifierOutcome

type VerifierOutcome string

VerifierOutcome is one of the four outcomes score reports for a row, in place of raw accuracy (ticket SOP-153 Observable behavior #5: accuracy averages over an asymmetry where one error truncates a caller and the other costs a re-ask).

const (
	OutcomeProceed         VerifierOutcome = "proceed"
	OutcomeSpuriousRepair  VerifierOutcome = "spurious_repair"
	OutcomeRepairFires     VerifierOutcome = "repair_fires"
	OutcomePartialAccepted VerifierOutcome = "partial_accepted"
)

Directories

Path Synopsis
Package assets embeds vendored binary model assets so the telephony package never needs a runtime file read to load them.
Package assets embeds vendored binary model assets so the telephony package never needs a runtime file read to load them.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL