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 { 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 }