Files

259 lines
8.7 KiB
Go
Raw Permalink Normal View History

package provider
import (
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"unicode/utf16"
)
// OSBuiltin speaks through the TTS runtime built into the operating system.
// It is the chain's provider of last resort: present everywhere, without a
// key and without network, but with the plainest voice.
//
// Platform mechanisms (selected at runtime via runtime.GOOS so that one
// binary compiles for all targets; every route is pure exec without a
// shell):
//
// Linux: Synthesize via espeak/espeak-ng (--stdout → WAV bytes).
// If both are missing, Synthesize returns ErrNoByteOutput and
// SpeakDirect plays via spd-say (speech-dispatcher); according
// to its own --help output spd-say can only emit audio itself
// through the daemon, not write it to a file or a process
// (verified on the dev host 2026-10-18).
// macOS: Synthesize via say (-o temporary WAV file,
// --data-format=LEI16@22050); voices de→Anna, en→Samantha
// (preinstalled).
// Windows: Synthesize via PowerShell System.Speech into a temporary WAV
// file (SetOutputToWaveFile); language via SelectVoiceByHints
// with fallback to the system voice.
//
// Missing binaries/runtimes lead to Available()==false or clear errors,
// never to panics.
type OSBuiltin struct{}
// Name implements Provider.
func (OSBuiltin) Name() string { return "osbuiltin" }
// Available implements Provider: true if at least one platform mechanism is
// present.
func (OSBuiltin) Available(ctx context.Context) bool {
switch runtime.GOOS {
case "darwin":
_, err := exec.LookPath("say")
return err == nil
case "windows":
_, err := exec.LookPath("powershell")
return err == nil
default: // linux and other unix-like systems
for _, bin := range []string{"espeak", "espeak-ng", "spd-say"} {
if _, err := exec.LookPath(bin); err == nil {
return true
}
}
return false
}
}
// Describe names the platform mechanism found, for diagnosis
// (`talk doctor`): e.g. "espeak-ng (audio bytes)", "spd-say (direct speech
// only; install espeak-ng for audio bytes)", "say", "powershell (SAPI)",
// or what is missing.
func (OSBuiltin) Describe() string {
switch runtime.GOOS {
case "darwin":
if _, err := exec.LookPath("say"); err == nil {
return "say (macOS)"
}
return "say not found"
case "windows":
if _, err := exec.LookPath("powershell"); err == nil {
return "powershell (SAPI System.Speech)"
}
return "powershell not found"
default:
for _, bin := range []string{"espeak-ng", "espeak"} {
if _, err := exec.LookPath(bin); err == nil {
return bin + " (audio bytes)"
}
}
if _, err := exec.LookPath("spd-say"); err == nil {
return "spd-say (direct speech only; install espeak-ng for audio bytes)"
}
return "neither espeak-ng/espeak nor spd-say found (install espeak-ng)"
}
}
// Synthesize implements Provider. Return format: RIFF/WAVE (PCM), playable
// through every sink of the audio package.
func (p OSBuiltin) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("osbuiltin: empty text")
}
switch runtime.GOOS {
case "darwin":
return p.synthSay(ctx, text, lang)
case "windows":
return p.synthSAPI(ctx, text, lang)
default:
return p.synthEspeak(ctx, text, lang)
}
}
// SpeakDirect implements DirectSpeaker: direct, blocking output through the
// OS TTS with its own playback. Required on Linux without espeak; not
// needed on macOS/Windows (Synthesize returns WAV) and a clear error there.
func (p OSBuiltin) SpeakDirect(ctx context.Context, text, lang string) error {
if text == "" {
return fmt.Errorf("osbuiltin: empty text")
}
switch runtime.GOOS {
case "darwin", "windows":
return fmt.Errorf("osbuiltin: SpeakDirect not needed on %s; Synthesize returns WAV, play it through a sink", runtime.GOOS)
default:
if _, err := exec.LookPath("spd-say"); err != nil {
return fmt.Errorf("osbuiltin: spd-say not found (install speech-dispatcher, or espeak for audio bytes): %w", err)
}
cmd := exec.CommandContext(ctx, "spd-say", "-w", "-l", lang, text)
if out, err := cmd.CombinedOutput(); err != nil {
if ctx.Err() != nil {
// Cancelled: killing the waiting client does not stop
// speech-dispatcher's output; tell it to stop explicitly.
_ = exec.Command("spd-say", "-S").Run()
return ctx.Err()
}
return fmt.Errorf("osbuiltin: spd-say failed: %w: %s", err, bytes.TrimSpace(out))
}
return nil
}
}
// synthEspeak tries espeak, then espeak-ng. Both write WAV (RIFF/PCM) to
// stdout.
func (OSBuiltin) synthEspeak(ctx context.Context, text, lang string) ([]byte, error) {
for _, bin := range []string{"espeak", "espeak-ng"} {
if _, err := exec.LookPath(bin); err != nil {
continue
}
cmd := exec.CommandContext(ctx, bin, "-v", lang, "--stdout", text)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("osbuiltin: %s failed: %w", bin, withStderr(err))
}
if len(out) == 0 {
return nil, fmt.Errorf("osbuiltin: %s returned no audio data", bin)
}
return out, nil
}
return nil, fmt.Errorf("osbuiltin: no espeak/espeak-ng for audio bytes found on %s: %w", runtime.GOOS, ErrNoByteOutput)
}
// synthSay produces WAV via the macOS say command into a temporary file.
// --data-format=LEI16@22050 forces 16-bit PCM; the .wav extension forces
// the WAVE container.
func (OSBuiltin) synthSay(ctx context.Context, text, lang string) ([]byte, error) {
if _, err := exec.LookPath("say"); err != nil {
return nil, fmt.Errorf("osbuiltin: say not found: %w", err)
}
tmp, err := os.CreateTemp("", "talk-say-*.wav")
if err != nil {
return nil, fmt.Errorf("osbuiltin: temporary file: %w", err)
}
name := tmp.Name()
tmp.Close()
defer os.Remove(name)
cmd := exec.CommandContext(ctx, "say", "-v", sayVoice(lang),
"--data-format=LEI16@22050", "-o", name, text)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("osbuiltin: say failed: %w: %s", err, bytes.TrimSpace(out))
}
data, err := os.ReadFile(name)
if err != nil {
return nil, fmt.Errorf("osbuiltin: reading say output: %w", err)
}
return data, nil
}
// sayVoice maps languages to preinstalled macOS voices.
func sayVoice(lang string) string {
if lang == "en" {
return "Samantha" // en-US
}
return "Anna" // de-DE
}
// synthSAPI produces WAV via the Windows SAPI (System.Speech) through
// PowerShell. The script is passed as -EncodedCommand (Base64/UTF-16LE) and
// the text inside it is passed base64-decoded, so there is no quoting
// problem with special characters. If the requested language is not
// installed, SAPI falls back to the system voice (try/catch).
func (OSBuiltin) synthSAPI(ctx context.Context, text, lang string) ([]byte, error) {
if _, err := exec.LookPath("powershell"); err != nil {
return nil, fmt.Errorf("osbuiltin: powershell not found: %w", err)
}
tmp, err := os.CreateTemp("", "talk-sapi-*.wav")
if err != nil {
return nil, fmt.Errorf("osbuiltin: temporary file: %w", err)
}
name := tmp.Name()
tmp.Close()
defer os.Remove(name)
script := fmt.Sprintf(`$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Speech
$s = New-Object System.Speech.Synthesis.SpeechSynthesizer
try {
$culture = New-Object System.Globalization.CultureInfo('%s')
$s.SelectVoiceByHints([System.Speech.Synthesis.VoiceGender]::Female, [System.Speech.Synthesis.VoiceAge]::Adult, 0, $culture)
} catch { }
$s.SetOutputToWaveFile('%s')
$s.Speak([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('%s')))
$s.Dispose()
`, sapiCulture(lang), name, base64.StdEncoding.EncodeToString([]byte(text)))
cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-STA", "-EncodedCommand", psEncode(script))
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("osbuiltin: SAPI failed: %w: %s", err, bytes.TrimSpace(out))
}
data, err := os.ReadFile(name)
if err != nil {
return nil, fmt.Errorf("osbuiltin: reading SAPI output: %w", err)
}
return data, nil
}
// sapiCulture maps languages to SAPI culture identifiers.
func sapiCulture(lang string) string {
if lang == "en" {
return "en-US"
}
return "de-DE"
}
// psEncode encodes a PowerShell script for -EncodedCommand
// (UTF-16LE + Base64).
func psEncode(script string) string {
runes := utf16.Encode([]rune(script))
buf := make([]byte, len(runes)*2)
for i, r := range runes {
binary.LittleEndian.PutUint16(buf[i*2:], r)
}
return base64.StdEncoding.EncodeToString(buf)
}
// withStderr enriches an exec error with the process's collected stderr
// output, if any.
func withStderr(err error) error {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 {
return fmt.Errorf("%w: %s", err, bytes.TrimSpace(exitErr.Stderr))
}
return err
}