diff --git a/cmd/speak.go b/cmd/speak.go index e043407..9b17e85 100644 --- a/cmd/speak.go +++ b/cmd/speak.go @@ -35,6 +35,7 @@ type speakOptions struct { normalize string lang string metrics bool + player string speakerBoost bool noSpeakerBoost bool @@ -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() @@ -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)") diff --git a/internal/audio/afplay.go b/internal/audio/afplay.go new file mode 100644 index 0000000..4c4c996 --- /dev/null +++ b/internal/audio/afplay.go @@ -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 +} diff --git a/internal/audio/afplay_other.go b/internal/audio/afplay_other.go new file mode 100644 index 0000000..8601bf1 --- /dev/null +++ b/internal/audio/afplay_other.go @@ -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") +} diff --git a/internal/audio/player_darwin.go b/internal/audio/player_darwin.go new file mode 100644 index 0000000..00c4fda --- /dev/null +++ b/internal/audio/player_darwin.go @@ -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) +} diff --git a/internal/audio/player_other.go b/internal/audio/player_other.go new file mode 100644 index 0000000..8edb949 --- /dev/null +++ b/internal/audio/player_other.go @@ -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) +} diff --git a/internal/audio/player.go b/internal/audio/player_oto.go similarity index 92% rename from internal/audio/player.go rename to internal/audio/player_oto.go index 9d17da2..1306174 100644 --- a/internal/audio/player.go +++ b/internal/audio/player_oto.go @@ -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)