From 1f78c8a9f4f41110a4f88e65f8e4c40b0a331b87 Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Tue, 6 Oct 2026 15:37:40 -0700 Subject: [PATCH] fix(cli): annotate speaker turns in `tts --multi-speaker` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default TTS model moved to `gemini-3.8-flash-tts`, which rejects a multi-speaker script sent as one text block ("Multi-speaker interactions must specify a speaker for each text turn.", HTTP 400). Every `--multi-speaker` call on the default model failed, including the `tts --help` example. Split the script into one text block per turn, each annotated with `speech_metadata` naming its speaker, and strip the `:` prefix from the turn text (Gemini 3.8 TTS treats `text` as a verbatim transcript and takes the speaker via `speech_metadata`). A turn starts at a `:` label for a declared `--multi-speaker` name: at the start of a line (optionally after a list or quote marker: `-`, `•`, `>`, `“`, `1.`) or mid-line after punctuation (`"Alice: hi. Bob: yo."`, `"Alice:Bob:"`), never inside a longer word (`"MaryAnn:"`, `"श्रीराम:"`) or after a word on the same line (`"I told Bob: wait."`), with any Unicode space around the name. Words before the first label, no label at all, or only empty turns are usage errors (exit 2) instead of the API's opaque 400. Legacy `gemini-2.5-*` and `gemini-3.1-*` TTS models reject `speech_metadata` annotations, so they keep the single plain block. The "speaker does not appear in the text" warning now follows the parsed turns on annotating models and keeps the word match on the legacy ones. `-f` / `--stdin` text drops a leading UTF-8 byte-order mark, so Windows-saved scripts parse. PiperOrigin-RevId: 994669788 --- internal/cli/custom/custom_test.go | 140 +++++++++++++++++++ internal/cli/custom/porcelain.go | 10 +- internal/cli/custom/tts.go | 212 +++++++++++++++++++++++++++-- 3 files changed, 349 insertions(+), 13 deletions(-) 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