da60c14399
- Dockerfile: multi-stage build with CGO_ENABLED=0, embedder model copy, non-root user, healthcheck, and /data volume. - docker-compose.yml: mavend + mavweb services with shared volume, health checks, and restart policy. - .gitignore: ignore models/llm/*.gguf, deploy/telegram.env, tmp artifacts. - deploy/mavend.json: add LLM, phraser, voice sections (embedder, model paths, wake sensitivity). Add telegram token env-var expansion. - deploy/telegram.env.example: template for telegram bot token. - internal/config/config.go: add LLM config struct, voice config struct (embedder, llama, wake sensitivity), telegram token loading. - telegramsink: add chat intent delivery support alongside existing types. - voicesink: skip empty payloads in delivery. - dialogue/session: add chat intent to anaphora resolution, test coverage. - AGENTS.md: update with LLM embedder, LFM model download/configure steps, new UI conventions. - REARCH.md: architecture research document. - cmd/mavend/main.go: wire LLM config, phraser, embedder, telegram config, WebAuthn, IPC event/routine handlers, and reactive notes.
111 lines
4.4 KiB
Go
111 lines
4.4 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/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
|