diff --git a/README.md b/README.md index 26ae657..1648de0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Local-first speech-to-text CLI. - single-instance command coordination via unix socket - audio capture via PipeWire/Pulse - streaming ASR via NVIDIA Riva gRPC -- transcript normalization + optional trailing space +- transcript normalization + sentence capitalization + optional trailing space - output adapters: - clipboard command (`clipboard_cmd`) - optional paste command override (`paste_cmd`) diff --git a/apps/sotto/internal/config/defaults.go b/apps/sotto/internal/config/defaults.go index 3ce90e7..d4d6269 100644 --- a/apps/sotto/internal/config/defaults.go +++ b/apps/sotto/internal/config/defaults.go @@ -18,7 +18,10 @@ func Default() Config { LanguageCode: "en-US", Model: "", }, - Transcript: TranscriptConfig{TrailingSpace: true}, + Transcript: TranscriptConfig{ + TrailingSpace: true, + CapitalizeSentences: true, + }, Indicator: IndicatorConfig{ Enable: true, Backend: "hypr", diff --git a/apps/sotto/internal/config/parser_jsonc.go b/apps/sotto/internal/config/parser_jsonc.go index 6050a6e..4ac565b 100644 --- a/apps/sotto/internal/config/parser_jsonc.go +++ b/apps/sotto/internal/config/parser_jsonc.go @@ -45,7 +45,8 @@ type jsoncASR struct { } type jsoncTranscript struct { - TrailingSpace *bool `json:"trailing_space"` + TrailingSpace *bool `json:"trailing_space"` + CapitalizeSentences *bool `json:"capitalize_sentences"` } type jsoncIndicator struct { @@ -176,8 +177,13 @@ func (payload jsoncConfig) applyTo(cfg *Config) ([]Warning, error) { } } - if payload.Transcript != nil && payload.Transcript.TrailingSpace != nil { - cfg.Transcript.TrailingSpace = *payload.Transcript.TrailingSpace + if payload.Transcript != nil { + if payload.Transcript.TrailingSpace != nil { + cfg.Transcript.TrailingSpace = *payload.Transcript.TrailingSpace + } + if payload.Transcript.CapitalizeSentences != nil { + cfg.Transcript.CapitalizeSentences = *payload.Transcript.CapitalizeSentences + } } if payload.Indicator != nil { diff --git a/apps/sotto/internal/config/parser_legacy.go b/apps/sotto/internal/config/parser_legacy.go index b58faea..4e4f164 100644 --- a/apps/sotto/internal/config/parser_legacy.go +++ b/apps/sotto/internal/config/parser_legacy.go @@ -218,6 +218,12 @@ func applyRootKey(cfg *Config, key, value string) error { return fmt.Errorf("invalid bool for transcript.trailing_space: %w", err) } cfg.Transcript.TrailingSpace = b + case "transcript.capitalize_sentences": + b, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid bool for transcript.capitalize_sentences: %w", err) + } + cfg.Transcript.CapitalizeSentences = b case "indicator.enable": b, err := strconv.ParseBool(value) if err != nil { diff --git a/apps/sotto/internal/config/parser_test.go b/apps/sotto/internal/config/parser_test.go index e7cdd6e..d4f7816 100644 --- a/apps/sotto/internal/config/parser_test.go +++ b/apps/sotto/internal/config/parser_test.go @@ -175,6 +175,18 @@ func TestParsePasteShortcut(t *testing.T) { } } +func TestParseTranscriptCapitalizeSentencesJSONC(t *testing.T) { + cfg, _, err := Parse(`{"transcript":{"capitalize_sentences":false}}`, Default()) + require.NoError(t, err) + require.False(t, cfg.Transcript.CapitalizeSentences) +} + +func TestParseTranscriptCapitalizeSentencesLegacy(t *testing.T) { + cfg, _, err := Parse("transcript.capitalize_sentences = false\n", Default()) + require.NoError(t, err) + require.False(t, cfg.Transcript.CapitalizeSentences) +} + func TestParseIndicatorBackend(t *testing.T) { cfg, _, err := Parse(` { diff --git a/apps/sotto/internal/config/types.go b/apps/sotto/internal/config/types.go index 5687a11..65f9f00 100644 --- a/apps/sotto/internal/config/types.go +++ b/apps/sotto/internal/config/types.go @@ -38,7 +38,8 @@ type ASRConfig struct { // TranscriptConfig controls transcript assembly formatting. type TranscriptConfig struct { - TrailingSpace bool + TrailingSpace bool + CapitalizeSentences bool } // IndicatorConfig controls visual indicator and audio cue behavior. diff --git a/apps/sotto/internal/pipeline/transcriber.go b/apps/sotto/internal/pipeline/transcriber.go index 7d26fc9..46bd3d1 100644 --- a/apps/sotto/internal/pipeline/transcriber.go +++ b/apps/sotto/internal/pipeline/transcriber.go @@ -189,7 +189,10 @@ func (t *Transcriber) StopAndTranscribe(ctx context.Context) (session.StopResult return result, fmt.Errorf("collect final transcript: %w", err) } - transcribed := transcript.Assemble(segments, t.cfg.Transcript.TrailingSpace) + transcribed := transcript.Assemble(segments, transcript.Options{ + TrailingSpace: t.cfg.Transcript.TrailingSpace, + CapitalizeSentences: t.cfg.Transcript.CapitalizeSentences, + }) rawPCM := capture.RawPCM() t.writeDebugAudio(rawPCM) t.closeDebugArtifacts() diff --git a/apps/sotto/internal/pipeline/transcriber_test.go b/apps/sotto/internal/pipeline/transcriber_test.go index 71c2012..fc463c0 100644 --- a/apps/sotto/internal/pipeline/transcriber_test.go +++ b/apps/sotto/internal/pipeline/transcriber_test.go @@ -260,7 +260,7 @@ func TestStopAndTranscribeSuccessPath(t *testing.T) { result, err := transcriber.StopAndTranscribe(context.Background()) require.NoError(t, err) - require.Equal(t, "hello world ", result.Transcript) + require.Equal(t, "Hello world ", result.Transcript) require.Equal(t, "Mic (mic-1)", result.AudioDevice) require.Equal(t, int64(4096), result.BytesCaptured) require.Equal(t, 12*time.Millisecond, result.GRPCLatency) diff --git a/apps/sotto/internal/transcript/assemble.go b/apps/sotto/internal/transcript/assemble.go index e426672..cf69783 100644 --- a/apps/sotto/internal/transcript/assemble.go +++ b/apps/sotto/internal/transcript/assemble.go @@ -3,8 +3,14 @@ package transcript import "strings" -// Assemble joins final ASR segments and applies whitespace/trailing-space normalization. -func Assemble(finalSegments []string, trailingSpace bool) string { +// Options controls transcript assembly formatting behavior. +type Options struct { + TrailingSpace bool + CapitalizeSentences bool +} + +// Assemble joins final ASR segments and applies configured normalization. +func Assemble(finalSegments []string, opts Options) string { if len(finalSegments) == 0 { return "" } @@ -15,8 +21,20 @@ func Assemble(finalSegments []string, trailingSpace bool) string { return "" } - if trailingSpace { + if opts.CapitalizeSentences { + normalized = capitalizeSentences(normalized) + } + + if opts.TrailingSpace { return normalized + " " } return normalized } + +func capitalizeSentences(text string) string { + text = capitalizeSentenceStarts(text) + text = pronounIContractionPattern.ReplaceAllStringFunc(text, func(match string) string { + return "I" + match[1:] + }) + return capitalizeStandalonePronounI(text) +} diff --git a/apps/sotto/internal/transcript/assemble_test.go b/apps/sotto/internal/transcript/assemble_test.go index 89dc704..e109287 100644 --- a/apps/sotto/internal/transcript/assemble_test.go +++ b/apps/sotto/internal/transcript/assemble_test.go @@ -6,37 +6,266 @@ import ( "github.com/stretchr/testify/require" ) -func TestAssembleNormalizesWhitespaceAndTrailingSpace(t *testing.T) { +func TestAssembleNormalizesWhitespaceTrailingSpaceAndSentenceCase(t *testing.T) { t.Parallel() - got := Assemble([]string{" hello", "world ", "\nfrom", "sotto"}, true) - require.Equal(t, "hello world from sotto ", got) + got := Assemble([]string{" hello", "world.", "\nfrom", "sotto"}, Options{ + TrailingSpace: true, + CapitalizeSentences: true, + }) + require.Equal(t, "Hello world. From sotto ", got) } func TestAssembleWithoutTrailingSpace(t *testing.T) { t.Parallel() - got := Assemble([]string{"hello", "world"}, false) + got := Assemble([]string{"hello", "world"}, Options{ + TrailingSpace: false, + CapitalizeSentences: false, + }) require.Equal(t, "hello world", got) } func TestAssembleEmptyInput(t *testing.T) { t.Parallel() - require.Empty(t, Assemble(nil, true)) + require.Empty(t, Assemble(nil, Options{TrailingSpace: true, CapitalizeSentences: true})) } func TestAssembleSkipsWhitespaceOnlySegments(t *testing.T) { t.Parallel() - got := Assemble([]string{" ", "\n\t", "hello"}, false) - require.Equal(t, "hello", got) + got := Assemble([]string{" ", "\n\t", "hello"}, Options{ + TrailingSpace: false, + CapitalizeSentences: true, + }) + require.Equal(t, "Hello", got) +} + +func TestAssembleSentenceCaseCapitalizesPronounI(t *testing.T) { + t.Parallel() + + got := Assemble([]string{"when i speak i'm clearer. i think i will keep using it."}, Options{ + TrailingSpace: false, + CapitalizeSentences: true, + }) + require.Equal(t, "When I speak I'm clearer. I think I will keep using it.", got) +} + +func TestAssembleSentenceCaseDoesNotCapitalizeDomainOrDecimalFragments(t *testing.T) { + t.Parallel() + + got := Assemble([]string{"check example.com and v2.1 first. then reply"}, Options{ + TrailingSpace: false, + CapitalizeSentences: true, + }) + require.Equal(t, "Check example.com and v2.1 first. Then reply", got) +} + +func TestAssembleSentenceCaseAbbreviationRegressionCorpus(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in string + want string + }{ + { + name: "measurement_tbsp", + in: "add 1 tbsp. sugar and stir", + want: "Add 1 tbsp. sugar and stir", + }, + { + name: "measurement_min", + in: "mix for 5 min. then serve", + want: "Mix for 5 min. then serve", + }, + { + name: "title_abbreviation_inside_sentence", + in: "we spoke with dr. smith yesterday. then we left", + want: "We spoke with dr. smith yesterday. Then we left", + }, + { + name: "ambiguous_etc_sentence_starter", + in: "we covered apples, etc. then moved on", + want: "We covered apples, etc. Then moved on", + }, + { + name: "ambiguous_etc_pronoun_boundary", + in: "we listed apples, etc. we moved on", + want: "We listed apples, etc. We moved on", + }, + { + name: "ambiguous_etc_conjunction_continuation", + in: "bring apples etc. and bananas", + want: "Bring apples etc. and bananas", + }, + { + name: "ambiguous_vs_conservative", + in: "compare this vs. that option. then decide", + want: "Compare this vs. that option. Then decide", + }, + { + name: "initialism_sentence_starter", + in: "we moved to the u.s. then we celebrated", + want: "We moved to the u.s. Then we celebrated", + }, + { + name: "initialism_pronoun_boundary", + in: "we moved to the u.s. we celebrated", + want: "We moved to the u.s. We celebrated", + }, + { + name: "initialism_embedded_locative_pronoun_boundary", + in: "i lived in the u.s. we can travel there", + want: "I lived in the u.s. We can travel there", + }, + { + name: "initialism_conjunction_continuation", + in: "we moved to the u.s. and stayed", + want: "We moved to the u.s. and stayed", + }, + { + name: "initialism_locative_non_boundary", + in: "in the u.s. we have states", + want: "In the u.s. we have states", + }, + { + name: "initialism_locative_non_boundary_after_sentence_boundary", + in: "this is true. in the u.s. we have states", + want: "This is true. In the u.s. we have states", + }, + { + name: "initialism_origin_non_boundary", + in: "from the u.s. we have exports", + want: "From the u.s. we have exports", + }, + { + name: "initialism_embedded_origin_boundary", + in: "i came from the u.s. we celebrated", + want: "I came from the u.s. We celebrated", + }, + { + name: "no_default_boundary", + in: "no. then we continue", + want: "No. Then we continue", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := Assemble([]string{tc.in}, Options{ + TrailingSpace: false, + CapitalizeSentences: true, + }) + require.Equal(t, tc.want, got) + }) + } +} + +func TestAssembleSentenceCaseDoesNotCapitalizeAfterCommonAbbreviations(t *testing.T) { + t.Parallel() + + got := Assemble([]string{"for i.e. this case and e.g. that case. then proceed"}, Options{ + TrailingSpace: false, + CapitalizeSentences: true, + }) + require.Equal(t, "For i.e. this case and e.g. that case. Then proceed", got) +} + +func TestAssembleSentenceCaseKeepsPronounIDistinctFromIEAbbreviation(t *testing.T) { + t.Parallel() + + got := Assemble([]string{"i said i.e. this should stay lowercase"}, Options{ + TrailingSpace: false, + CapitalizeSentences: true, + }) + require.Equal(t, "I said i.e. this should stay lowercase", got) +} + +func TestAssembleSentenceCaseKeepsLeadingIEAbbreviationLowercase(t *testing.T) { + t.Parallel() + + got := Assemble([]string{"i.e. this should stay lowercase"}, Options{ + TrailingSpace: false, + CapitalizeSentences: true, + }) + require.Equal(t, "i.e. this should stay lowercase", got) +} + +func TestAssembleSentenceCaseKeepsPostBoundaryIEAbbreviationLowercase(t *testing.T) { + t.Parallel() + + got := Assemble([]string{"this is true. i.e. this should stay lowercase"}, Options{ + TrailingSpace: false, + CapitalizeSentences: true, + }) + require.Equal(t, "This is true. i.e. this should stay lowercase", got) +} + +func TestAssembleSentenceCaseCapitalizesTitleAbbreviationAtSentenceStart(t *testing.T) { + t.Parallel() + + got := Assemble([]string{"dr. smith can help"}, Options{ + TrailingSpace: false, + CapitalizeSentences: true, + }) + require.Equal(t, "Dr. smith can help", got) +} + +func TestAssembleSentenceCaseCapitalizesTitleAbbreviationAfterBoundary(t *testing.T) { + t.Parallel() + + got := Assemble([]string{"this happened. dr. smith replied"}, Options{ + TrailingSpace: false, + CapitalizeSentences: true, + }) + require.Equal(t, "This happened. Dr. smith replied", got) +} + +func TestAssembleSentenceCaseDoesNotCapitalizeAfterInitialismAbbreviation(t *testing.T) { + t.Parallel() + + got := Assemble([]string{"in the u.s. government report. then we continue"}, Options{ + TrailingSpace: false, + CapitalizeSentences: true, + }) + require.Equal(t, "In the u.s. government report. Then we continue", got) +} + +func TestAssembleSentenceCaseHandlesQuoteAfterBoundary(t *testing.T) { + t.Parallel() + + got := Assemble([]string{"he said. \"hello there\" and left."}, Options{ + TrailingSpace: false, + CapitalizeSentences: true, + }) + require.Equal(t, "He said. \"Hello there\" and left.", got) +} + +func TestAssembleSentenceCaseLeadingBoundaryDoesNotDoubleCapitalize(t *testing.T) { + t.Parallel() + + got := Assemble([]string{"2. hello there"}, Options{ + TrailingSpace: false, + CapitalizeSentences: true, + }) + require.Equal(t, "2. Hello there", got) } func TestAssembleIdempotentForNormalizedOutput(t *testing.T) { t.Parallel() - first := Assemble([]string{"hello", "world"}, false) - second := Assemble([]string{first}, false) + first := Assemble([]string{"hello world. this is sotto"}, Options{ + TrailingSpace: false, + CapitalizeSentences: true, + }) + second := Assemble([]string{first}, Options{ + TrailingSpace: false, + CapitalizeSentences: true, + }) require.Equal(t, first, second) } diff --git a/apps/sotto/internal/transcript/boundary_classifier.go b/apps/sotto/internal/transcript/boundary_classifier.go new file mode 100644 index 0000000..167183d --- /dev/null +++ b/apps/sotto/internal/transcript/boundary_classifier.go @@ -0,0 +1,387 @@ +package transcript + +import ( + "strings" + "unicode" +) + +type abbreviationBoundaryClass uint8 + +const ( + abbreviationBoundaryNonTerminal abbreviationBoundaryClass = iota + abbreviationBoundaryAmbiguous +) + +type periodBoundaryReason string + +const ( + periodBoundaryReasonDefault periodBoundaryReason = "default" + periodBoundaryReasonEmbeddedToken periodBoundaryReason = "embedded-token" + periodBoundaryReasonDecimal periodBoundaryReason = "decimal" + periodBoundaryReasonInitialism periodBoundaryReason = "initialism" + periodBoundaryReasonInitialismBoundary periodBoundaryReason = "initialism-boundary" + periodBoundaryReasonKnownAbbreviation periodBoundaryReason = "known-abbreviation" + periodBoundaryReasonAmbiguousConservative periodBoundaryReason = "ambiguous-abbreviation-conservative" + periodBoundaryReasonAmbiguousBoundary periodBoundaryReason = "ambiguous-abbreviation-boundary" +) + +var ( + // lowercaseSentenceAbbreviations should stay lowercase even at sentence starts. + lowercaseSentenceAbbreviations = map[string]struct{}{ + "e.g": {}, + "etc": {}, + "i.e": {}, + "vs": {}, + } + + // sentenceBoundaryAbbreviationClasses classifies tokens that frequently appear + // before a non-terminal period. + sentenceBoundaryAbbreviationClasses = map[string]abbreviationBoundaryClass{ + // Latin/editorial abbreviations. + "e.g": abbreviationBoundaryNonTerminal, + "i.e": abbreviationBoundaryNonTerminal, + "cf": abbreviationBoundaryNonTerminal, + "etc": abbreviationBoundaryAmbiguous, + "vs": abbreviationBoundaryAmbiguous, + + // Titles/honorifics. + "dr": abbreviationBoundaryNonTerminal, + "mr": abbreviationBoundaryNonTerminal, + "mrs": abbreviationBoundaryNonTerminal, + "ms": abbreviationBoundaryNonTerminal, + "prof": abbreviationBoundaryNonTerminal, + "sr": abbreviationBoundaryNonTerminal, + "jr": abbreviationBoundaryNonTerminal, + + // Reference markers. + "ch": abbreviationBoundaryNonTerminal, + "eq": abbreviationBoundaryNonTerminal, + "fig": abbreviationBoundaryNonTerminal, + "ref": abbreviationBoundaryNonTerminal, + "sec": abbreviationBoundaryNonTerminal, + + // Units/time abbreviations frequently used in dictation. + "hr": abbreviationBoundaryNonTerminal, + "hrs": abbreviationBoundaryNonTerminal, + "lb": abbreviationBoundaryNonTerminal, + "lbs": abbreviationBoundaryNonTerminal, + "min": abbreviationBoundaryNonTerminal, + "mins": abbreviationBoundaryNonTerminal, + "oz": abbreviationBoundaryNonTerminal, + "tbsp": abbreviationBoundaryNonTerminal, + "tsp": abbreviationBoundaryNonTerminal, + } + + // lowercaseBoundaryPromoters captures lowercase words that strongly indicate + // a sentence boundary after ambiguous abbreviations/initialisms in ASR text. + // Keep this list intentionally narrow to avoid false positives like + // `etc. and` or `u.s. and`. + lowercaseBoundaryPromoters = map[string]struct{}{ + "finally": {}, + "however": {}, + "meanwhile": {}, + "next": {}, + "then": {}, + "therefore": {}, + } + + lowercasePronounBoundaryPromoters = map[string]struct{}{ + "he": {}, + "i": {}, + "it": {}, + "she": {}, + "they": {}, + "we": {}, + "you": {}, + } + + locativePrepositions = map[string]struct{}{ + "across": {}, + "around": {}, + "at": {}, + "from": {}, + "in": {}, + "inside": {}, + "near": {}, + "outside": {}, + "through": {}, + "throughout": {}, + "to": {}, + "within": {}, + } +) + +func isSentenceBoundaryPeriod(runes []rune, idx int) bool { + boundary, _ := classifyPeriodBoundary(runes, idx) + return boundary +} + +func classifyPeriodBoundary(runes []rune, idx int) (bool, periodBoundaryReason) { + if idx < 0 || idx >= len(runes) || runes[idx] != '.' { + return false, periodBoundaryReasonDefault + } + + if isDecimalPeriod(runes, idx) { + return false, periodBoundaryReasonDecimal + } + + if isEmbeddedPeriodToken(runes, idx) { + return false, periodBoundaryReasonEmbeddedToken + } + + token := strings.ToLower(sentenceTokenBeforePeriod(runes, idx)) + if token == "" { + return true, periodBoundaryReasonDefault + } + if class, ok := sentenceBoundaryAbbreviationClasses[token]; ok { + switch class { + case abbreviationBoundaryNonTerminal: + return false, periodBoundaryReasonKnownAbbreviation + case abbreviationBoundaryAmbiguous: + if shouldTreatAbbreviationAsBoundary(runes, idx, token) { + return true, periodBoundaryReasonAmbiguousBoundary + } + return false, periodBoundaryReasonAmbiguousConservative + } + } + + if looksLikeInitialismToken(token) { + if shouldTreatAbbreviationAsBoundary(runes, idx, token) { + return true, periodBoundaryReasonInitialismBoundary + } + return false, periodBoundaryReasonInitialism + } + + return true, periodBoundaryReasonDefault +} + +func isDecimalPeriod(runes []rune, idx int) bool { + if idx <= 0 || idx+1 >= len(runes) { + return false + } + return unicode.IsDigit(runes[idx-1]) && unicode.IsDigit(runes[idx+1]) +} + +func isEmbeddedPeriodToken(runes []rune, idx int) bool { + if idx+1 >= len(runes) { + return false + } + + next := runes[idx+1] + return unicode.IsLetter(next) || unicode.IsDigit(next) || next == '.' +} + +func shouldTreatAbbreviationAsBoundary(runes []rune, idx int, token string) bool { + nextWordStart := nextSentenceWordStart(runes, idx+1) + if nextWordStart < 0 { + return true + } + if unicode.IsUpper(runes[nextWordStart]) { + return true + } + + nextWord := strings.ToLower(sentenceWordFromIndex(runes, nextWordStart)) + if isLowercaseBoundaryPromoter(nextWord) { + return true + } + if !isLowercasePronounBoundaryPromoter(nextWord) { + return false + } + if looksLikeInitialismToken(token) && isLikelyLocativeInitialismContinuation(runes, idx) { + return false + } + return true +} + +func sentenceWordFromIndex(runes []rune, idx int) string { + if idx < 0 || idx >= len(runes) { + return "" + } + + end := idx + for end < len(runes) { + if unicode.IsLetter(runes[end]) { + end++ + continue + } + break + } + + return string(runes[idx:end]) +} + +func nextSentenceWordStart(runes []rune, start int) int { + for i := start; i < len(runes); i++ { + r := runes[i] + switch { + case unicode.IsSpace(r): + continue + case isSentencePrefixRune(r): + continue + case unicode.IsLetter(r): + return i + default: + return -1 + } + } + return -1 +} + +func isLowercaseSentenceAbbreviation(token string) bool { + _, ok := lowercaseSentenceAbbreviations[token] + return ok +} + +func isLowercaseBoundaryPromoter(word string) bool { + _, ok := lowercaseBoundaryPromoters[word] + return ok +} + +func isLowercasePronounBoundaryPromoter(word string) bool { + _, ok := lowercasePronounBoundaryPromoters[word] + return ok +} + +func isLikelyLocativeInitialismContinuation(runes []rune, idx int) bool { + tokenStart := sentenceTokenStart(runes, idx) + if tokenStart < 0 { + return false + } + + prevWord, prevStart := previousWordBeforeIndex(runes, tokenStart) + if prevWord == "" { + return false + } + if isLocativePreposition(prevWord) { + return isSentenceLeadingWord(runes, prevStart) + } + + if !isArticleWord(prevWord) || prevStart <= 0 { + return false + } + + prepositionWord, prepositionStart := previousWordBeforeIndex(runes, prevStart) + if !isLocativePreposition(prepositionWord) { + return false + } + return isSentenceLeadingWord(runes, prepositionStart) +} + +func sentenceTokenStart(runes []rune, idx int) int { + if idx <= 0 || idx >= len(runes) { + return -1 + } + + start := idx - 1 + for start >= 0 { + if r := runes[start]; unicode.IsLetter(r) || r == '.' { + start-- + continue + } + break + } + + return start + 1 +} + +func previousWordBeforeIndex(runes []rune, idx int) (string, int) { + if idx <= 0 || idx > len(runes) { + return "", -1 + } + + i := idx - 1 + for i >= 0 && !unicode.IsLetter(runes[i]) { + i-- + } + if i < 0 { + return "", -1 + } + + end := i + 1 + for i >= 0 && unicode.IsLetter(runes[i]) { + i-- + } + start := i + 1 + return strings.ToLower(string(runes[start:end])), start +} + +func isLocativePreposition(word string) bool { + _, ok := locativePrepositions[word] + return ok +} + +func isArticleWord(word string) bool { + switch word { + case "a", "an", "the": + return true + default: + return false + } +} + +func isSentenceLeadingWord(runes []rune, wordStart int) bool { + if wordStart <= 0 { + return true + } + + i := wordStart - 1 + for i >= 0 { + r := runes[i] + switch { + case unicode.IsSpace(r): + i-- + continue + case isSentencePrefixRune(r): + i-- + continue + } + break + } + + if i < 0 { + return true + } + switch runes[i] { + case '.', '!', '?': + return true + default: + return false + } +} + +func sentenceTokenBeforePeriod(runes []rune, idx int) string { + if idx <= 0 || idx >= len(runes) { + return "" + } + + start := idx - 1 + for start >= 0 { + if r := runes[start]; unicode.IsLetter(r) || r == '.' { + start-- + continue + } + break + } + + return strings.Trim(string(runes[start+1:idx]), ".") +} + +func looksLikeInitialismToken(token string) bool { + if !strings.ContainsRune(token, '.') { + return false + } + + parts := strings.Split(token, ".") + if len(parts) < 2 { + return false + } + + for _, part := range parts { + runes := []rune(part) + if len(runes) != 1 || !unicode.IsLetter(runes[0]) { + return false + } + } + + return true +} diff --git a/apps/sotto/internal/transcript/pronoun_case.go b/apps/sotto/internal/transcript/pronoun_case.go new file mode 100644 index 0000000..6519784 --- /dev/null +++ b/apps/sotto/internal/transcript/pronoun_case.go @@ -0,0 +1,56 @@ +package transcript + +import ( + "regexp" + "strings" + "unicode" + "unicode/utf8" +) + +var ( + pronounIContractionPattern = regexp.MustCompile(`\bi['’](?:m|d|ll|ve|re|s)\b`) + pronounIWordPattern = regexp.MustCompile(`\bi\b`) +) + +func capitalizeStandalonePronounI(text string) string { + matches := pronounIWordPattern.FindAllStringIndex(text, -1) + if len(matches) == 0 { + return text + } + + var out strings.Builder + out.Grow(len(text)) + + last := 0 + for _, match := range matches { + start, end := match[0], match[1] + out.WriteString(text[last:start]) + if shouldSkipPronounICapitalization(text, start, end) { + out.WriteString(text[start:end]) + } else { + out.WriteString("I") + } + last = end + } + + out.WriteString(text[last:]) + return out.String() +} + +func shouldSkipPronounICapitalization(text string, start int, end int) bool { + if end+1 < len(text) && text[end] == '.' { + nextRune, _ := utf8.DecodeRuneInString(text[end+1:]) + if unicode.IsLetter(nextRune) { + return true + } + } + + if start > 1 && text[start-1] == '.' && end < len(text) && text[end] == '.' { + prevRune, _ := utf8.DecodeLastRuneInString(text[:start-1]) + if unicode.IsLetter(prevRune) { + return true + } + } + + return false +} diff --git a/apps/sotto/internal/transcript/sentence_case.go b/apps/sotto/internal/transcript/sentence_case.go new file mode 100644 index 0000000..8b632ef --- /dev/null +++ b/apps/sotto/internal/transcript/sentence_case.go @@ -0,0 +1,102 @@ +package transcript + +import ( + "strings" + "unicode" +) + +func capitalizeSentenceStarts(text string) string { + runes := []rune(text) + + var out strings.Builder + out.Grow(len(text)) + + capitalizeStart := true + pendingBoundary := false + sawWhitespaceAfterBoundary := false + + for i, r := range runes { + if capitalizeStart && unicode.IsLetter(r) { + if shouldCapitalizeWordAt(runes, i) { + r = unicode.ToUpper(r) + } + capitalizeStart = false + pendingBoundary = false + sawWhitespaceAfterBoundary = false + } else if pendingBoundary { + switch { + case unicode.IsSpace(r): + sawWhitespaceAfterBoundary = true + case unicode.IsLetter(r): + if sawWhitespaceAfterBoundary && shouldCapitalizeWordAt(runes, i) { + r = unicode.ToUpper(r) + } + pendingBoundary = false + sawWhitespaceAfterBoundary = false + case unicode.IsDigit(r): + pendingBoundary = false + sawWhitespaceAfterBoundary = false + case isSentencePrefixRune(r): + // Keep waiting for a letter. This supports punctuation like: . "quote" + default: + if !sawWhitespaceAfterBoundary { + pendingBoundary = false + sawWhitespaceAfterBoundary = false + } + } + } + + out.WriteRune(r) + + switch r { + case '.': + if isSentenceBoundaryPeriod(runes, i) { + pendingBoundary = true + sawWhitespaceAfterBoundary = false + } else { + pendingBoundary = false + sawWhitespaceAfterBoundary = false + } + case '!', '?': + pendingBoundary = true + sawWhitespaceAfterBoundary = false + } + } + + return out.String() +} + +func shouldCapitalizeWordAt(runes []rune, idx int) bool { + token := strings.ToLower(strings.Trim(wordTokenFromIndex(runes, idx), ".")) + if token == "" { + return true + } + return !isLowercaseSentenceAbbreviation(token) +} + +func wordTokenFromIndex(runes []rune, idx int) string { + if idx < 0 || idx >= len(runes) { + return "" + } + + end := idx + for end < len(runes) { + r := runes[end] + if unicode.IsLetter(r) || r == '.' { + end++ + continue + } + break + } + + return string(runes[idx:end]) +} + +func isSentencePrefixRune(r rune) bool { + switch r { + case ')', ']', '}', '\'', '"', '’', '”': + return true + default: + return false + } +} diff --git a/docs/architecture.md b/docs/architecture.md index afadab4..8a27234 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,7 +12,7 @@ flowchart LR Session --> Audio["Audio capture\n(PipeWire/Pulse)"] Session --> ASR["Riva streaming client\n(gRPC)"] - ASR --> Transcript["Transcript assembly\n(normalize + trailing space)"] + ASR --> Transcript["Transcript assembly\n(normalize + sentence case + trailing space)"] Transcript --> Output["Output adapters\n(clipboard + paste)"] Session --> Indicator["Indicator adapters\n(hypr or desktop) + cues"] diff --git a/docs/configuration.md b/docs/configuration.md index c2cd935..b745eb1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -73,6 +73,7 @@ Top-level object keys: | Key | Default | Notes | | --- | --- | --- | | `transcript.trailing_space` | `true` | append space after assembled transcript | +| `transcript.capitalize_sentences` | `true` | sentence-case output and promote standalone `i`/`i'm` to `I`/`I'm` | ### `indicator` @@ -153,7 +154,8 @@ default-timeout=0 }, "transcript": { - "trailing_space": true + "trailing_space": true, + "capitalize_sentences": true }, "indicator": {