4f34a232d4
PROGRESS.md and 20-07-2026-BACKLOG.md were state snapshots that git log and the Vikunja board already carry. Everything PROGRESS.md claimed as shipped is a QA task. The backlog's only untracked item, bounded follow-up state, is now V-448. maven-feature-ranking.md moves to docs/archive/2026-07-03-feature-ranking.md instead of dying. Its mandatory and easy tiers became V-449 through V-458; the doable and epic tiers are reasoning about why things are not worth doing yet, which no task captures. Four code comments and the design.md ledger pointed at the deleted files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xwomr2cT93KMSz9u3digX5
111 lines
4.3 KiB
Go
111 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.
|
|
//
|
|
// 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/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
|
|
}
|
|
|
|
// 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
|