Files
Maven/internal/delivery/voicesink/voicesink.go
T
2026-07-03 00:32:48 +02:00

109 lines
4.3 KiB
Go

// 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