8c36e7ef84
A clock time read aloud now inflects its nouns and drops its leading zeros.
The old rewrite said "часов" for every hour and "минут" for every minute, so
21:00 came out as "21 часов" and 14:00 as "14 часов 00 минут". Russian
inflects a noun after a numeral and internal/say already owns that rule, so
spokenTime calls say.CountWord for both halves and omits the minutes when
there are none. 21:00 is "21 час", 22:02 is "22 часа 2 минуты", 14:00 is
"14 часов".
internal/persona held its own copies of the twelve months and the seven
weekdays. Both are closed classes and both already live in internal/lexicon,
which is where cmd/mavend/ruwords.go sent its copy. The block now reads
lexicon.Weekday and lexicon.MonthGenitive and carries no word list of its own.
LoadSummaries asserted that stall_sitting keeps its two counts and not the two
count words beside them. A variant dropping {word} or {dayword} would have
loaded and spoken a bare number. Both are required now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
84 lines
2.9 KiB
Go
84 lines
2.9 KiB
Go
// Package ttsnorm rewrites machine-formatted dates/times/numbers into RU text
|
|
// a TTS voice speaks naturally — so "10.07.2026" is not read as "number dot
|
|
// number dot number". Pure, deterministic; runs on reply/nudge text before synth.
|
|
package ttsnorm
|
|
|
|
import (
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/kami/maven/internal/lexicon"
|
|
"github.com/kami/maven/internal/say"
|
|
)
|
|
|
|
// The month names are a closed class and live in internal/lexicon, 1-indexed,
|
|
// which is also where the voice reply path reads them. There used to be a second
|
|
// copy of the twelve names in cmd/mavend/ruwords.go (Vikunja #525).
|
|
|
|
var (
|
|
reDateY = regexp.MustCompile(`\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b`)
|
|
reDate = regexp.MustCompile(`\b(\d{1,2})\.(\d{1,2})\b`)
|
|
reTime = regexp.MustCompile(`\b(\d{1,2}):(\d{2})\b`)
|
|
reDots = regexp.MustCompile(`\b\d+(?:\.\d+){2,}\b`)
|
|
)
|
|
|
|
// Speakable rewrites d.m.y, d.m, h:mm, and residual dotted-number runs.
|
|
// Order matters: dates with year first, then times, then multi-dot numbers
|
|
// (3+ parts — never valid dates), then 2-part dates.
|
|
func Speakable(s string) string {
|
|
s = reDateY.ReplaceAllStringFunc(s, func(m string) string {
|
|
p := reDateY.FindStringSubmatch(m)
|
|
return spokenDate(p[1], p[2], p[3])
|
|
})
|
|
s = reTime.ReplaceAllStringFunc(s, func(m string) string {
|
|
p := reTime.FindStringSubmatch(m)
|
|
return spokenTime(mustInt(p[1]), mustInt(p[2]))
|
|
})
|
|
s = reDots.ReplaceAllStringFunc(s, func(m string) string {
|
|
return strings.Join(strings.Split(m, "."), " точка ")
|
|
})
|
|
s = reDate.ReplaceAllStringFunc(s, func(m string) string {
|
|
p := reDate.FindStringSubmatch(m)
|
|
return spokenDate(p[1], p[2], "")
|
|
})
|
|
// Last, so a hostname is spelled out after the numbers around it are
|
|
// already words and no rewrite above can see Cyrillic it did not expect.
|
|
return Pronounce(s)
|
|
}
|
|
|
|
// spokenTime reads a clock time the way it is said rather than the way it is
|
|
// written. Two things the written form gets wrong out loud. The noun after a
|
|
// numeral inflects, so 21:00 is "час" and 22:00 is "часа", where the old
|
|
// rewrite said "часов" for every hour and "минут" for every minute. And a
|
|
// leading zero is punctuation, not a word: 14:00 is "14 часов" and 9:05 is
|
|
// "9 часов 5 минут", never "00 минут" or "05 минут".
|
|
func spokenTime(h, m int) string {
|
|
out := strconv.Itoa(h) + " " + say.CountWord(h, "час", "часа", "часов")
|
|
if m == 0 {
|
|
return out
|
|
}
|
|
return out + " " + strconv.Itoa(m) + " " + say.CountWord(m, "минута", "минуты", "минут")
|
|
}
|
|
|
|
func spokenDate(dd, mm, yyyy string) string {
|
|
mi, _ := strconv.Atoi(mm)
|
|
if mi < 1 || mi > 12 {
|
|
return dd + " " + mm + gap(yyyy)
|
|
}
|
|
day := strconv.Itoa(mustInt(dd))
|
|
out := day + " " + lexicon.MonthGenitive(mi)
|
|
if yyyy != "" {
|
|
out += " " + yyyy
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mustInt(s string) int { n, _ := strconv.Atoi(s); return n }
|
|
func gap(y string) string {
|
|
if y == "" {
|
|
return ""
|
|
}
|
|
return " " + y
|
|
}
|