// Package voicesink implements delivery.Sink for the voice channel. // // It is the LAST mile of proactive voice delivery: the dispatcher calls // voicesink.Send for a PhrasedNudge whose routing landed on ChannelVoice. // The sink: // // 1. renders the Sendable.Body (the full phrased message) to PCM via the // tts.Synthesizer seam. The phraser already produced the text; the // voice sink synthesises AUDIO for it. Body, not Summary — voice is the // local channel; no shoulder-surf exfil concern (no relay); the user // hears the full message. // // 2. finds the most-recently-active voice client session via the // voice.Sessions registry and pushes the audio as a voice.AudioNudgePush // on that session's conn. The user's most-recently-active client plays // it; other clients stay silent (no dogpile — the spec's "play it on // most-recently-active, reroute to ntfy/telegram if none reachable"). // // 3. if no live session exists, Send returns voice.ErrNoSession // (wrapped). The daemon logs the partial dispatch; an OPEN deferred // question is whether the dispatcher should reroute to away-channels // instead of returning partial — listed in PROGRESS.md. // // Import direction: voicesink imports internal/tts (synth seam) and // internal/voice (Sessions registry). Both are siblings of delivery; the // dispatcher holds `delivery.Sink` and doesn't import voicesink, so the // import cycle is broken. voicesink is wired at the daemon. package voicesink import ( "context" "errors" "fmt" "log" "time" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/tts" "github.com/kami/maven/internal/voice" ) // Sink — implements delivery.Sink for the voice channel via TTS synthesis + // per-session push. type Sink struct { tts tts.Synthesizer sess *voice.Sessions } // New builds a Sink wired to a Synthesizer and the Sessions registry shared // with the voice.Server. The daemon constructs one when its config enables // voice; otherwise the dispatcher's voice slot stays nil and the routing // table's ChannelVoice selections drop silently (today's behaviour). func New(synth tts.Synthesizer, sess *voice.Sessions) *Sink { return &Sink{tts: synth, sess: sess} } // Send — delivery.Sink. Synthesises Body text, picks the most-recently- // active session, pushes the audio. Errors ⇒ the dispatcher surfaces // partial dispatch; a deferred question is rerouting to away channels when // no live voice session exists (today the dispatcher errors and stops; // tomorrow: reroute on voice.ErrNoSession within the dispatcher or here). func (s *Sink) Send(ctx context.Context, send delivery.Sendable) error { if s.tts == nil { return fmt.Errorf("voicesink: tts synthesizer not wired") } if s.sess == nil { return fmt.Errorf("voicesink: sessions registry not wired") } text := send.Body if text == "" { // voice gets Body; if the phraser didn't produce one, fall back to // Summary (terse full beats silence — the routing table insisted // on voice for this severity, so a silent no-op would hide a bug // in the phraser behind routing). text = send.Summary } out, err := s.tts.Synthesize(ctx, text) if err != nil { return fmt.Errorf("voicesink: synthesize: %w", err) } if !out.Format.IsValid() { // A synthesizer returning an off-spec format is a model bug; refuse // to ship bytes the client can't play rather than misrouting to a // decoder that doesn't exist. return fmt.Errorf("voicesink: tts returned %v, want PCM16kMono", out.Format) } push := voice.AudioNudgePush{ RuleName: send.RuleName, Severity: int(send.Severity), Audio: out, Text: text, Ts: time.Now(), } if err := s.sess.PushToMostRecent(ctx, push); err != nil { if errors.Is(err, voice.ErrNoSession) { log.Printf("voicesink: no live voice session for %s — rerouting deferred, dropping", send.RuleName) return err } return fmt.Errorf("voicesink: push: %w", err) } return nil } // keep audio import honest (used in Send's audio.PCM check indirect via // Format.IsValid which is a method on the imported audio.Format). The alias // below keeps the import alive even if a future refactor moves the only // reference. Today, the synthesizer's audio.Audio directly flows through. var _ = audio.PCM16kMono