2026-10-18 15:39:01 +02:00
|
|
|
package provider
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
"encoding/binary"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"runtime"
|
|
|
|
|
"unicode/utf16"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 15:39:01 +02:00
|
|
|
//
|
2026-10-24 11:15:35 +02:00
|
|
|
// Platform mechanisms (selected at runtime via runtime.GOOS so that one
|
|
|
|
|
// binary compiles for all targets; every route is pure exec without a
|
|
|
|
|
// shell):
|
2026-10-18 15:39:01 +02:00
|
|
|
//
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 15:39:01 +02:00
|
|
|
//
|
2026-10-24 11:15:35 +02:00
|
|
|
// Missing binaries/runtimes lead to Available()==false or clear errors,
|
|
|
|
|
// never to panics.
|
2026-10-18 15:39:01 +02:00
|
|
|
type OSBuiltin struct{}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Name implements Provider.
|
2026-10-18 15:39:01 +02:00
|
|
|
func (OSBuiltin) Name() string { return "osbuiltin" }
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Available implements Provider: true if at least one platform mechanism is
|
|
|
|
|
// present.
|
2026-10-18 15:39:01 +02:00
|
|
|
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
|
2026-10-24 11:15:35 +02:00
|
|
|
default: // linux and other unix-like systems
|
2026-10-18 15:39:01 +02:00
|
|
|
for _, bin := range []string{"espeak", "espeak-ng", "spd-say"} {
|
|
|
|
|
if _, err := exec.LookPath(bin); err == nil {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-24 10:08:44 +02:00
|
|
|
func (OSBuiltin) Describe() string {
|
|
|
|
|
switch runtime.GOOS {
|
|
|
|
|
case "darwin":
|
|
|
|
|
if _, err := exec.LookPath("say"); err == nil {
|
|
|
|
|
return "say (macOS)"
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
return "say not found"
|
2026-10-24 10:08:44 +02:00
|
|
|
case "windows":
|
|
|
|
|
if _, err := exec.LookPath("powershell"); err == nil {
|
|
|
|
|
return "powershell (SAPI System.Speech)"
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
return "powershell not found"
|
2026-10-24 10:08:44 +02:00
|
|
|
default:
|
|
|
|
|
for _, bin := range []string{"espeak-ng", "espeak"} {
|
|
|
|
|
if _, err := exec.LookPath(bin); err == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return bin + " (audio bytes)"
|
2026-10-24 10:08:44 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if _, err := exec.LookPath("spd-say"); err == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return "spd-say (direct speech only; install espeak-ng for audio bytes)"
|
2026-10-24 10:08:44 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
return "neither espeak-ng/espeak nor spd-say found (install espeak-ng)"
|
2026-10-24 10:08:44 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Synthesize implements Provider. Return format: RIFF/WAVE (PCM), playable
|
|
|
|
|
// through every sink of the audio package.
|
2026-10-18 15:39:01 +02:00
|
|
|
func (p OSBuiltin) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
|
|
|
|
|
if text == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("osbuiltin: empty text")
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 15:39:01 +02:00
|
|
|
func (p OSBuiltin) SpeakDirect(ctx context.Context, text, lang string) error {
|
|
|
|
|
if text == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
return fmt.Errorf("osbuiltin: empty text")
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
switch runtime.GOOS {
|
|
|
|
|
case "darwin", "windows":
|
2026-10-24 11:15:35 +02:00
|
|
|
return fmt.Errorf("osbuiltin: SpeakDirect not needed on %s; Synthesize returns WAV, play it through a sink", runtime.GOOS)
|
2026-10-18 15:39:01 +02:00
|
|
|
default:
|
|
|
|
|
if _, err := exec.LookPath("spd-say"); err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return fmt.Errorf("osbuiltin: spd-say not found (install speech-dispatcher, or espeak for audio bytes): %w", err)
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
cmd := exec.CommandContext(ctx, "spd-say", "-w", "-l", lang, text)
|
|
|
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
2026-10-24 12:26:38 +02:00
|
|
|
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()
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
return fmt.Errorf("osbuiltin: spd-say failed: %w: %s", err, bytes.TrimSpace(out))
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// synthEspeak tries espeak, then espeak-ng. Both write WAV (RIFF/PCM) to
|
|
|
|
|
// stdout.
|
2026-10-18 15:39:01 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("osbuiltin: %s failed: %w", bin, withStderr(err))
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
if len(out) == 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("osbuiltin: %s returned no audio data", bin)
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
return out, nil
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("osbuiltin: no espeak/espeak-ng for audio bytes found on %s: %w", runtime.GOOS, ErrNoByteOutput)
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 15:39:01 +02:00
|
|
|
func (OSBuiltin) synthSay(ctx context.Context, text, lang string) ([]byte, error) {
|
|
|
|
|
if _, err := exec.LookPath("say"); err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("osbuiltin: say not found: %w", err)
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
tmp, err := os.CreateTemp("", "talk-say-*.wav")
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("osbuiltin: temporary file: %w", err)
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("osbuiltin: say failed: %w: %s", err, bytes.TrimSpace(out))
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
data, err := os.ReadFile(name)
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("osbuiltin: reading say output: %w", err)
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
return data, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// sayVoice maps languages to preinstalled macOS voices.
|
2026-10-18 15:39:01 +02:00
|
|
|
func sayVoice(lang string) string {
|
|
|
|
|
if lang == "en" {
|
|
|
|
|
return "Samantha" // en-US
|
|
|
|
|
}
|
|
|
|
|
return "Anna" // de-DE
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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).
|
2026-10-18 15:39:01 +02:00
|
|
|
func (OSBuiltin) synthSAPI(ctx context.Context, text, lang string) ([]byte, error) {
|
|
|
|
|
if _, err := exec.LookPath("powershell"); err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("osbuiltin: powershell not found: %w", err)
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
tmp, err := os.CreateTemp("", "talk-sapi-*.wav")
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("osbuiltin: temporary file: %w", err)
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("osbuiltin: SAPI failed: %w: %s", err, bytes.TrimSpace(out))
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
data, err := os.ReadFile(name)
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("osbuiltin: reading SAPI output: %w", err)
|
2026-10-18 15:39:01 +02:00
|
|
|
}
|
|
|
|
|
return data, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// sapiCulture maps languages to SAPI culture identifiers.
|
2026-10-18 15:39:01 +02:00
|
|
|
func sapiCulture(lang string) string {
|
|
|
|
|
if lang == "en" {
|
|
|
|
|
return "en-US"
|
|
|
|
|
}
|
|
|
|
|
return "de-DE"
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// psEncode encodes a PowerShell script for -EncodedCommand
|
2026-10-18 15:39:01 +02:00
|
|
|
// (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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// withStderr enriches an exec error with the process's collected stderr
|
|
|
|
|
// output, if any.
|
2026-10-18 15:39:01 +02:00
|
|
|
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
|
|
|
|
|
}
|