2063f8e770
audio.PCM16kMono was only referenced by a `var _ =` placeholder whose comment claimed to keep the import "honest" for a method call that doesn't need it — out.Format's IsValid() is a method value, calling it never requires importing the package that defines the type. The import had no other use in the file, so both it and the placeholder were dead.
104 lines
4.0 KiB
Go
104 lines
4.0 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.
|
|
//
|
|
// 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/delivery"
|
|
"github.com/kami/maven/internal/tts"
|
|
"github.com/kami/maven/internal/ttsnorm"
|
|
"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, ttsnorm.Speakable(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, falling through to away channels", send.RuleName)
|
|
return delivery.ErrVoiceNoSession
|
|
}
|
|
return fmt.Errorf("voicesink: push: %w", err)
|
|
}
|
|
return nil
|
|
}
|