diff --git a/internal/cli/custom/custom_test.go b/internal/cli/custom/custom_test.go index 3ab8f17..1c30853 100644 --- a/internal/cli/custom/custom_test.go +++ b/internal/cli/custom/custom_test.go @@ -20,6 +20,7 @@ import ( "path/filepath" "reflect" "regexp" + "slices" "strings" "testing" @@ -481,6 +482,145 @@ func TestBuildSpeechConfig(t *testing.T) { } } +func TestSpeakerTurns(t *testing.T) { + turn := func(speaker, text string) speakerTurn { return speakerTurn{speaker: speaker, text: text} } + alice, bob := turn("Alice", "hi."), turn("Bob", "yo.") + valid := []struct { + text string + speakers []string + want []speakerTurn + }{ + {"Alice: hi. Bob: yo.", []string{"Alice", "Bob"}, []speakerTurn{alice, bob}}, + {" Alice: hi.\nBob:yo.\n\nAlice : bye.\n", []string{"Alice", "Bob"}, []speakerTurn{alice, bob, turn("Alice", "bye.")}}, + {"Alice: hi.\r\nBob: yo.\r\n", []string{"Alice", "Bob"}, []speakerTurn{alice, bob}}, + {"Alice: hi\nBob: yo", []string{"Alice", "Bob"}, []speakerTurn{turn("Alice", "hi"), turn("Bob", "yo")}}, + // Every rune strings.Fields splits on separates labels and name words. + {"Alice: hi.\u00a0Bob: yo.", []string{"Alice", "Bob"}, []speakerTurn{alice, bob}}, + {"Alice: hi.\u3000Bob\u2028: yo.", []string{"Alice", "Bob"}, []speakerTurn{alice, bob}}, + {"Dr\u00a0 Who: hi.", []string{"Dr Who"}, []speakerTurn{turn("Dr Who", "hi.")}}, + // A label may follow punctuation or a line break, but not a word on the same line. + {"Alice: \"hi.\"Bob: \"yo.\"", []string{"Alice", "Bob"}, []speakerTurn{turn("Alice", "\"hi.\""), turn("Bob", "\"yo.\"")}}, + {"Ann: hi. MaryAnn: x. 2Ann: y. Ann: yo.", []string{"Ann", "Bo"}, []speakerTurn{turn("Ann", "hi. MaryAnn: x. 2Ann: y."), turn("Ann", "yo.")}}, + {"Alice: I told Bob: wait here.\nBob: yo.", []string{"Alice", "Bob"}, []speakerTurn{turn("Alice", "I told Bob: wait here."), bob}}, + // Line-initial bullets before speaker labels are stripped cleanly on every turn. + {"- Alice: hi.\n- Bob: yo.", []string{"Alice", "Bob"}, []speakerTurn{alice, bob}}, + {"* Alice: hi.\n• Bob: yo.", []string{"Alice", "Bob"}, []speakerTurn{alice, bob}}, + {"> Alice: hi.\n >> Bob: yo.", []string{"Alice", "Bob"}, []speakerTurn{alice, bob}}, + {"1. Alice: hi.\n2) Bob: yo.\n10. Alice: bye.", []string{"Alice", "Bob"}, []speakerTurn{alice, bob, turn("Alice", "bye.")}}, + // A quote opening the line goes with the label; a closing one stays in the turn. + {"\"Alice: hi.\"\n\"Bob: yo.\"", []string{"Alice", "Bob"}, []speakerTurn{turn("Alice", "hi.\""), turn("Bob", "yo.\"")}}, + {"\u201cAlice: hi.\u201d Bob: yo.", []string{"Alice", "Bob"}, []speakerTurn{turn("Alice", "hi.\u201d"), bob}}, + // Punctuation or digits after words on the same line stay in the previous turn. + {"Alice: hi. - Bob: yo.", []string{"Alice", "Bob"}, []speakerTurn{turn("Alice", "hi. -"), bob}}, + {"Alice: I have 2. Bob: yo.", []string{"Alice", "Bob"}, []speakerTurn{turn("Alice", "I have 2."), bob}}, + {"Alice: 42. Bob: That is correct.", []string{"Alice", "Bob"}, []speakerTurn{turn("Alice", "42."), turn("Bob", "That is correct.")}}, + // A combining mark is part of the word: neither "\u0936\u094d\u0930\u0940\u0930\u093e\u092e:" nor "\u0930\u093e\u092e\u0940:" holds a label for "\u0930\u093e\u092e". + {"Alice: \u0936\u094d\u0930\u0940\u0930\u093e\u092e: \u0930\u093e\u092e\u0940: hi. \u0930\u093e\u092e: yo.", []string{"Alice", "\u0930\u093e\u092e"}, []speakerTurn{turn("Alice", "\u0936\u094d\u0930\u0940\u0930\u093e\u092e: \u0930\u093e\u092e\u0940: hi."), turn("\u0930\u093e\u092e", "yo.")}}, + // Marker scanning stops at the previous label: names made of marker runes + // stay labels, and a marker right after an empty label goes with the next. + {"\U0001F600: \U0001F916: hi.", []string{"\U0001F600", "\U0001F916"}, []speakerTurn{turn("\U0001F916", "hi.")}}, + {"\U0001F600: hi.\n\U0001F916: yo.", []string{"\U0001F600", "\U0001F916"}, []speakerTurn{turn("\U0001F600", "hi."), turn("\U0001F916", "yo.")}}, + {"Alice: - Bob: yo.", []string{"Alice", "Bob"}, []speakerTurn{bob}}, + // "Annual:" is not a label for "Ann"; an undeclared "Carol:" stays in the turn. + {"Ann: Annual: report. Carol: yes. Bo: ok.", []string{"Ann", "Bo"}, []speakerTurn{turn("Ann", "Annual: report. Carol: yes."), turn("Bo", "ok.")}}, + // The longer name wins over a declared prefix of it. + {"Alice: hi. Al: yo.", []string{"Al", "Alice"}, []speakerTurn{alice, turn("Al", "yo.")}}, + {"Dr Who: hi.\nA.B: yo. AxB: no.", []string{"Dr Who", "A.B"}, []speakerTurn{turn("Dr Who", "hi."), turn("A.B", "yo. AxB: no.")}}, + // A label with nothing to say is dropped, whether or not space follows. + {"Alice: Bob: yo.", []string{"Alice", "Bob"}, []speakerTurn{bob}}, + {"Alice:Bob: yo.", []string{"Alice", "Bob"}, []speakerTurn{bob}}, + } + for _, tt := range valid { + if got, err := speakerTurns(tt.text, tt.speakers); err != nil || !reflect.DeepEqual(got, tt.want) { + t.Errorf("speakerTurns(%q, %q) = %+v, %v; want %+v", tt.text, tt.speakers, got, err, tt.want) + } + } + + for text, wantErr := range map[string]string{ + "hi. Bob: yo.": `text before the first speaker label: "hi."`, + "- hi. Bob: yo.": `text before the first speaker label: "- hi."`, + "(intro) Bob: yo.": `text before the first speaker label: "(intro)"`, + "1.5 Bob: yo.": `text before the first speaker label: "1.5"`, + strings.Repeat("é", 70) + " Bob: x": `label: "` + strings.Repeat("é", 60) + `…"`, + "alice: hi. bob: yo.": "the text has no speaker label", + "Alice: \n Bob: \n": "every speaker turn is empty", + } { + if got, err := speakerTurns(text, []string{"Alice", "Bob"}); err == nil || !strings.Contains(err.Error(), wantErr) { + t.Errorf("speakerTurns(%q) = %+v, %v; want an error containing %q", text, got, err, wantErr) + } + } +} + +func TestTTSInput(t *testing.T) { + speech := func(flags ...string) *interactions.SpeechConfigUnion { + t.Helper() + s, _, err := buildSpeechConfig(ttsCommand(t, flags...)) + if err != nil { + t.Fatal(err) + } + return s + } + single, multi := speech(), speech("--multi-speaker", "Alice=Kore,Bob=Puck") + const script = "Alice: hi. Bob: yo." + plain := []interactions.Content{textContentBlock(script)} + annotated := func(speaker, text string) interactions.Content { + return interactions.CreateContentText(interactions.TextContent{ + Text: text, + Annotations: []interactions.Annotation{interactions.CreateAnnotationSpeechMetadata( + interactions.SpeechAnnotation{Speaker: stringPtr(speaker)})}, + }) + } + turns := []interactions.Content{annotated("Alice", "hi."), annotated("Bob", "yo.")} + for _, tt := range []struct { + model string + speech *interactions.SpeechConfigUnion + want []interactions.Content + }{ + {defaultTTSModel, single, plain}, + {defaultTTSModel, multi, turns}, + {"gemini-3.8-flash-lite-tts", multi, turns}, + {"gemini-3.1-flash-tts-preview", multi, plain}, + {"gemini-2.5-flash-preview-tts", multi, plain}, + {"gemini-2.5-pro-preview-tts", multi, plain}, + {"gemini-2.5-flash-tts", multi, plain}, + {"gemini-2.5-pro-tts", multi, plain}, + } { + if got, absent, err := ttsInput(tt.model, script, tt.speech); err != nil || absent != nil || !reflect.DeepEqual(got, tt.want) { + t.Errorf("ttsInput(%s, multi=%t) = %+v, %q, %v; want %+v", tt.model, tt.speech.SpeakerConfig != nil, got, absent, err, tt.want) + } + } + + // Absent speakers follow the parsed turns on current models and a word + // match on legacy ones. + for _, tt := range []struct { + model, text string + speech *interactions.SpeechConfigUnion + want []string + }{ + {defaultTTSModel, "Dr Who: hi. Bob: yo.", speech("--multi-speaker", "Dr Who=Kore,Bob=Puck"), nil}, + {defaultTTSModel, "Alice: hi, Bob.", multi, []string{"Bob"}}, + {"gemini-3.1-flash-tts-preview", "Alice: hi, Bob.", multi, nil}, + {"gemini-3.1-flash-tts-preview", "Alice: hi.", multi, []string{"Bob"}}, + {"gemini-3.1-flash-tts-preview", "Alice: \u0936\u094d\u0930\u0940\u0930\u093e\u092e \u0930\u093e\u092e\u0940.", speech("--multi-speaker", "Alice=Kore,\u0930\u093e\u092e=Puck"), []string{"\u0930\u093e\u092e"}}, + } { + if _, absent, err := ttsInput(tt.model, tt.text, tt.speech); err != nil || !slices.Equal(absent, tt.want) { + t.Errorf("ttsInput(%s, %q) absent = %q, %v; want %q", tt.model, tt.text, absent, err, tt.want) + } + } +} + +func TestDecodeText(t *testing.T) { + for in, want := range map[string]string{ + "\uFEFFAlice: hi.\r\n": "Alice: hi.", + " hi \n": "hi", + "hi\uFEFF": "hi\uFEFF", + } { + if got := decodeText([]byte(in)); got != want { + t.Errorf("decodeText(%q) = %q, want %q", in, got, want) + } + } +} + func TestNormalizeIdentifier(t *testing.T) { newWrappedCmd := func(t *testing.T, flagName, shorthand, desc string, normalize func(string) (string, error), gotVal *string, called *bool) *cobra.Command { t.Helper() diff --git a/internal/cli/custom/porcelain.go b/internal/cli/custom/porcelain.go index a85715b..e81b190 100644 --- a/internal/cli/custom/porcelain.go +++ b/internal/cli/custom/porcelain.go @@ -211,6 +211,12 @@ func resolveModel(cmd *cobra.Command, def string) (string, error) { func stringPtr(s string) *string { return &s } func boolPtr(b bool) *bool { return &b } +// decodeText trims file or stdin text, dropping the UTF-8 byte-order mark +// Windows editors prepend. +func decodeText(data []byte) string { + return strings.TrimSpace(strings.TrimPrefix(string(data), "\uFEFF")) +} + // textInput resolves a single text input from exactly one of: positional args, // a -- file, or -- stdin. More than one source is a usage // error; the returned text is trimmed and non-empty (or "" when none supplied). @@ -240,7 +246,7 @@ func textInput(cmd *cobra.Command, args []string, fileFlag, stdinFlag string) (s if err != nil { return "", usageError(fmt.Sprintf("cannot read --%s %q: %v", fileFlag, filePath, err)) } - text := strings.TrimSpace(string(data)) + text := decodeText(data) if text == "" { return "", usageError(fmt.Sprintf("--%s %q is empty", fileFlag, filePath)) } @@ -250,7 +256,7 @@ func textInput(cmd *cobra.Command, args []string, fileFlag, stdinFlag string) (s if err != nil { return "", usageError(fmt.Sprintf("cannot read stdin: %v", err)) } - text := strings.TrimSpace(string(data)) + text := decodeText(data) if text == "" { return "", usageError("stdin is empty") } diff --git a/internal/cli/custom/tts.go b/internal/cli/custom/tts.go index 6bad593..fff13ea 100644 --- a/internal/cli/custom/tts.go +++ b/internal/cli/custom/tts.go @@ -15,11 +15,15 @@ package custom import ( + "cmp" "encoding/base64" "errors" "fmt" "regexp" + "slices" "strings" + "unicode" + "unicode/utf8" "github.com/google-gemini/gemini-api-cli/internal/client" "github.com/google-gemini/gemini-api-cli/internal/flagutil" @@ -47,7 +51,7 @@ func attachTTS(cmd *cobra.Command) { "full request control via gemini-api agent run") cmd.Args = cobra.ArbitraryArgs cmd.Flags().StringP("voice", "v", defaultTTSVoice, "Voice for single-speaker audio (e.g. Kore, Puck, Zephyr, Charon, Fenrir, Aoede)") - cmd.Flags().String("multi-speaker", "", "Speaker→voice map for two-speaker scripts, e.g. \"Alice=Kore,Bob=Puck\"") + cmd.Flags().String("multi-speaker", "", "Speaker→voice map for two-speaker scripts, e.g. \"Alice=Kore,Bob=Puck\"; start each turn with its speaker label (\"Alice: …\")") cmd.Flags().StringP("file", "f", "", "Read the input text from a file") cmd.Flags().Bool("stdin", false, "Read the input text from stdin") cmd.Flags().String("out", "", "Output file or directory; the extension follows the audio (default: ./gemini-tts--.wav)") @@ -80,6 +84,10 @@ func runTTS(cmd *cobra.Command, args []string) error { if err != nil { return err } + input, absent, err := ttsInput(model, text, speech) + if err != nil { + return err + } // The format stays bare: the TTS models reject every explicit mime_type // and delivery ("not supported for models/…"), and answer with 24 kHz L16. responseFormat := interactions.CreateCreateModelInteractionResponseFormatResponseFormat( @@ -87,19 +95,15 @@ func runTTS(cmd *cobra.Command, args []string) error { // Warnings reach a human on stderr and a machine in the result envelope; // structured stderr stays reserved for the error document. var warnings []string - if speech.SpeakerConfig != nil { - // The API does not check names against the script; a stray one is - // silently voiced wrong. - for _, sp := range speech.SpeakerConfig.Speakers { - if !containsWord(text, *sp.Speaker) { - warnings = append(warnings, fmt.Sprintf("speaker %q does not appear in the text", *sp.Speaker)) - } - } + // The API does not check names against the script; a stray one is + // silently voiced wrong. + for _, speaker := range absent { + warnings = append(warnings, fmt.Sprintf("speaker %q does not appear in the text", speaker)) } for _, warning := range warnings { progress(cmd, "warning: %s", warning) } - body := newModelInteraction(model, textContentBlock(text)) + body := newModelInteraction(model, input...) body.ResponseFormat = &responseFormat body.GenerationConfig = &interactions.GenerationConfig{SpeechConfig: speech} req := operations.CreateInteractionRequest{ @@ -183,10 +187,196 @@ func runTTS(cmd *cobra.Command, args []string) error { // containsWord reports whether text names word on its own rather than inside a // longer one ("Ann" is absent from "Annual"). func containsWord(text, word string) bool { - edge := `[^\p{L}\p{N}]` + edge := `[^\p{L}\p{M}\p{N}]` return regexp.MustCompile(`(^|` + edge + `)` + regexp.QuoteMeta(word) + `($|` + edge + `)`).MatchString(text) } +// isLegacyMultiSpeakerModel reports whether model takes a multi-speaker script +// as one plain text block and rejects speech_metadata annotations ("not +// supported for this model"). Every other model rejects that block and needs +// one annotated block per turn. +func isLegacyMultiSpeakerModel(model string) bool { + return strings.HasPrefix(model, "gemini-2.5-") || strings.HasPrefix(model, "gemini-3.1-") +} + +// ttsInput builds the interaction input: the whole text as one block, or, for a +// multi-speaker script on a current model, one block per speaker turn annotated +// with its speaker. It also returns the declared speakers the script never +// voices. +func ttsInput(model, text string, speech *interactions.SpeechConfigUnion) ([]interactions.Content, []string, error) { + if speech.SpeakerConfig == nil { + return []interactions.Content{textContentBlock(text)}, nil, nil + } + speakers := make([]string, 0, len(speech.SpeakerConfig.Speakers)) + for _, sp := range speech.SpeakerConfig.Speakers { + speakers = append(speakers, *sp.Speaker) + } + var absent []string + if isLegacyMultiSpeakerModel(model) { + for _, speaker := range speakers { + if !containsWord(text, speaker) { + absent = append(absent, speaker) + } + } + return []interactions.Content{textContentBlock(text)}, absent, nil + } + turns, err := speakerTurns(text, speakers) + if err != nil { + return nil, nil, err + } + voiced := map[string]bool{} + input := make([]interactions.Content, 0, len(turns)) + for _, turn := range turns { + voiced[turn.speaker] = true + input = append(input, interactions.CreateContentText(interactions.TextContent{ + Text: turn.text, + Annotations: []interactions.Annotation{interactions.CreateAnnotationSpeechMetadata( + interactions.SpeechAnnotation{Speaker: stringPtr(turn.speaker)})}, + })) + } + for _, speaker := range speakers { + if !voiced[speaker] { + absent = append(absent, speaker) + } + } + return input, absent, nil +} + +// isWordRune reports whether r belongs to a word. Combining marks count: in +// "श्रीराम" the vowel sign before "राम" is part of the word, not a boundary. +func isWordRune(r rune) bool { return unicode.IsLetter(r) || unicode.IsMark(r) || unicode.IsNumber(r) } + +func isLineBreak(r rune) bool { + return r == '\n' || r == '\r' || r == '\u0085' || r == '\u2028' || r == '\u2029' +} + +func isHorizontalSpace(r rune) bool { + return !isLineBreak(r) && (unicode.IsSpace(r) || unicode.In(r, unicode.Z)) +} + +// isMarkerRune reports whether r can make up a list or quote marker: neither a +// letter, a digit, nor a space. +func isMarkerRune(r rune) bool { + return !isWordRune(r) && !unicode.IsSpace(r) && !unicode.In(r, unicode.Z) +} + +// lineMarkerStart returns where the list or quote marker opening the line of +// the label at start begins ("- Alice:", "> Alice:", "“Alice:", "1. Alice:"), +// so the marker goes with the label instead of the previous turn or the text +// before the first label. Without one, it returns start: punctuation or a +// number after words on the same line ("hi. - Bob:", "Alice: 42. Bob:") stays +// in the previous turn. The scan stops at floor, the end of the previous +// label, so a name made of marker runes ("😀: 🤖:") is never claimed as the +// next label's marker. +func lineMarkerStart(text string, floor, start int) int { + before := strings.TrimRightFunc(text[floor:start], isHorizontalSpace) + rest := strings.TrimRightFunc(before, isMarkerRune) + if rest == before { + return start + } + check := rest + if marker := before[len(rest):]; marker == "." || marker == ")" { + if digits := strings.TrimRightFunc(rest, unicode.IsDigit); len(digits) < len(rest) { + rest, check = digits, text[:floor+len(digits)] + } + } + if line := strings.TrimRightFunc(check, isHorizontalSpace); line != "" { + if last, _ := utf8.DecodeLastRuneInString(line); !isLineBreak(last) { + return start + } + } + return floor + len(rest) +} + +type speakerTurn struct { + speaker string + text string +} + +// labelSpace matches every rune strings.Fields splits on, so a label agrees +// with the --multi-speaker name it was declared as ("Dr\u00a0Who:" is "Dr Who"). +const labelSpace = `[\s\v\x{85}\p{Z}]` + +// speakerTurns splits a script at each ":" label that names one of +// speakers and strips the label from the turn text (Gemini 3.8 TTS treats text +// as a verbatim transcript and takes the speaker via speech_metadata). A label +// may sit at the start of a line (optionally after a list or quote marker: +// "-", "•", ">", "“", "1.") +// or mid-line after punctuation ("Alice: hi. Bob: hello.", "Alice:Bob:"), but +// not inside a longer word ("MaryAnn:") or after a word on the same line +// ("I told Bob: wait."). Labels for undeclared speakers are not split on, and +// turns with nothing to say are dropped. Every block must name a speaker, so +// text before the first label is an error. +func speakerTurns(text string, speakers []string) ([]speakerTurn, error) { + // Longest first, so "Alice" wins over a declared "Al". + sorted := slices.Clone(speakers) + slices.SortFunc(sorted, func(a, b string) int { return cmp.Compare(len(b), len(a)) }) + names := make([]string, len(sorted)) + for i, speaker := range sorted { + words := strings.Fields(speaker) + for j, word := range words { + words[j] = regexp.QuoteMeta(word) + } + names[i] = strings.Join(words, labelSpace+`+`) + } + label := regexp.MustCompile(`(` + strings.Join(names, "|") + `)` + labelSpace + `*:`) + hint := `Start each turn with its speaker label, e.g. "Alice: hi. Bob: hello."` + + // RE2 has no lookbehind: matches inside a longer word ("MaryAnn:") or after + // a word on the same line ("I told Bob:") are skipped by hand, resuming one + // rune later so they cannot hide a real label. + type labelSpan struct{ start, nameStart, nameEnd, end int } + var labels []labelSpan + for pos := 0; pos < len(text); { + m := label.FindStringSubmatchIndex(text[pos:]) + if m == nil { + break + } + start := pos + m[0] + before := strings.TrimRightFunc(text[:start], isHorizontalSpace) + if prev, _ := utf8.DecodeLastRuneInString(before); len(before) > 0 && isWordRune(prev) && (len(before) == start || len(labels) > 0) { + _, size := utf8.DecodeRuneInString(text[start:]) + pos = start + size + continue + } + floor := 0 + if len(labels) > 0 { + floor = labels[len(labels)-1].end + } + labels = append(labels, labelSpan{lineMarkerStart(text, floor, start), pos + m[0], pos + m[3], pos + m[1]}) + pos += m[1] + } + if len(labels) == 0 { + return nil, usageError("--multi-speaker: the text has no speaker label", hint, + "Labels are case-sensitive and must match the --multi-speaker names") + } + if lead := strings.TrimSpace(text[:labels[0].start]); lead != "" { + if runes := []rune(lead); len(runes) > 60 { + lead = string(runes[:60]) + "…" + } + return nil, usageError(fmt.Sprintf("--multi-speaker: text before the first speaker label: %q", lead), hint) + } + var turns []speakerTurn + for i, l := range labels { + end := len(text) + if i+1 < len(labels) { + end = labels[i+1].start + } + turnText := strings.TrimSpace(text[l.end:end]) + if turnText == "" { + continue + } + turns = append(turns, speakerTurn{ + speaker: strings.Join(strings.Fields(text[l.nameStart:l.nameEnd]), " "), + text: turnText, + }) + } + if len(turns) == 0 { + return nil, usageError("--multi-speaker: every speaker turn is empty", hint) + } + return turns, nil +} + // buildSpeechConfig turns --voice / --multi-speaker / --language into the // interaction's speech configuration: a single unnamed voice (array-of-one) or, // for --multi-speaker, a per-speaker map. The returned label names the voice