Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions cmd/speak.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type speakOptions struct {
normalize string
lang string
metrics bool
player string

speakerBoost bool
noSpeakerBoost bool
Expand Down Expand Up @@ -105,6 +106,24 @@ func init() {
}
}

// Resolve player backend.
playerChoice := opts.player
if playerChoice == "" || playerChoice == "auto" {
if env := os.Getenv("SAG_PLAYER"); env != "" {
playerChoice = env
}
}
switch playerChoice {
case "", "auto":
// Default: build tags route StreamToSpeakers to the right backend.
case "afplay":
playToSpeakers = audio.StreamViaAfplay
case "oto":
playToSpeakers = audio.StreamViaOto
default:
return fmt.Errorf("unknown player %q; choose auto, afplay, or oto", playerChoice)
}

ctx, cancel := context.WithTimeout(cmd.Context(), 90*time.Second)
defer cancel()

Expand Down Expand Up @@ -156,6 +175,7 @@ func init() {
cmd.Flags().StringVar(&opts.normalize, "normalize", "", "Text normalization: auto|on|off (numbers/units/URLs; when set)")
cmd.Flags().StringVar(&opts.lang, "lang", "", "Language code (2-letter ISO 639-1; influences normalization; when set)")
cmd.Flags().BoolVar(&opts.metrics, "metrics", false, "Print request metrics to stderr (chars, bytes, duration, etc.)")
cmd.Flags().StringVar(&opts.player, "player", "auto", "Audio backend: auto (afplay on macOS, oto elsewhere), afplay, oto (SAG_PLAYER)")
cmd.Flags().StringVarP(&opts.inputFile, "input-file", "f", "", "Read text from file (use '-' for stdin), matching macOS say -f")
cmd.Flags().Bool("progress", false, "Accepted for macOS say compatibility (no-op)")
cmd.Flags().String("network-send", "", "Accepted for macOS say compatibility (not implemented)")
Expand Down
34 changes: 34 additions & 0 deletions internal/audio/afplay.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//go:build darwin

package audio

import (
"context"
"fmt"
"io"
"os"
"os/exec"
)

// StreamViaAfplay plays MP3 audio using macOS afplay, which correctly handles AirPlay devices.
func StreamViaAfplay(ctx context.Context, r io.Reader) error {
tmp, err := os.CreateTemp("", "sag-*.mp3")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
defer os.Remove(tmp.Name())

if _, err := io.Copy(tmp, r); err != nil {
_ = tmp.Close()
return fmt.Errorf("write audio to temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}

cmd := exec.CommandContext(ctx, "afplay", tmp.Name())
if err := cmd.Run(); err != nil {
return fmt.Errorf("afplay: %w", err)
}
return nil
}
14 changes: 14 additions & 0 deletions internal/audio/afplay_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//go:build !darwin

package audio

import (
"context"
"errors"
"io"
)

// StreamViaAfplay is not available on non-macOS platforms.
func StreamViaAfplay(_ context.Context, _ io.Reader) error {
return errors.New("afplay backend is only available on macOS")
}
13 changes: 13 additions & 0 deletions internal/audio/player_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build darwin

package audio

import (
"context"
"io"
)

// StreamToSpeakers plays MP3 audio via macOS afplay, which correctly handles AirPlay devices.
func StreamToSpeakers(ctx context.Context, r io.Reader) error {
return StreamViaAfplay(ctx, r)
}
13 changes: 13 additions & 0 deletions internal/audio/player_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build !darwin

package audio

import (
"context"
"io"
)

// StreamToSpeakers plays MP3 audio via the oto backend (CoreAudio/ALSA/etc.).
func StreamToSpeakers(ctx context.Context, r io.Reader) error {
return StreamViaOto(ctx, r)
}
4 changes: 2 additions & 2 deletions internal/audio/player.go → internal/audio/player_oto.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ var (
audioContextErr error
)

// StreamToSpeakers decodes MP3 audio from the reader and plays it to the default output device.
func StreamToSpeakers(ctx context.Context, r io.Reader) error {
// StreamViaOto decodes MP3 audio from the reader and plays it via the oto backend (CoreAudio/ALSA/etc.).
func StreamViaOto(ctx context.Context, r io.Reader) error {
decoder, err := mp3.NewDecoder(r)
if err != nil {
return fmt.Errorf("decode mp3: %w", err)
Expand Down