109 lines
4.3 KiB
Go
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 |