first commit

This commit is contained in:
2026-06-04 18:10:52 +07:00
commit b5c083e06f
105 changed files with 8172 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
package config
import "strings"
type API struct {
Addr string `yaml:"addr"`
ModelsDir string `yaml:"models_dir"`
CacheDir string `yaml:"cache_dir"`
// DefaultModel: whisper model id for OpenAI /v1/audio/transcriptions (maps whisper-1).
DefaultModel string `yaml:"default_model"`
Language string `yaml:"language"`
Transcript Transcript `yaml:"transcript"`
DefaultSpeakers bool `yaml:"default_speakers"`
Prompt string `yaml:"prompt"`
Threads uint `yaml:"threads"`
MaxContext uint `yaml:"max_context"`
BeamSize uint `yaml:"beam_size"`
EntropyThold float64 `yaml:"entropy_thold"`
VAD VAD `yaml:"vad"`
Debug bool `yaml:"debug"`
SpeedUp bool `yaml:"speedup"`
Translate bool `yaml:"translate"`
DefaultPunctuation bool `yaml:"default_punctuation"`
// DefaultAsync: STT via API enqueues to cache/waiting and returns taskID (use ?async=0 for sync).
DefaultAsync bool `yaml:"default_async"`
// Garbage: artifact substrings removed from transcript text and words (default includes *выбая*).
Garbage []string `yaml:"garbage"`
}
func (a API) WithDefaults() API {
a.VAD = a.VAD.WithDefaults()
a.Transcript = a.Transcript.WithDefaults()
if strings.TrimSpace(a.Language) == "" {
a.Language = "ru"
}
return a
}
// GarbagePatterns returns garbage filter list (never empty unless explicitly set to [] in YAML).
func (a API) GarbagePatterns() []string {
return a.garbagePatterns()
}
+72
View File
@@ -0,0 +1,72 @@
package config
import (
"os"
"path/filepath"
)
type Diarization struct {
Enabled bool `yaml:"enabled"`
ModelDir string `yaml:"model_dir"`
SegmentationModel string `yaml:"segmentation_model"`
EmbeddingModel string `yaml:"embedding_model"`
NumThreads int `yaml:"num_threads"`
NumClusters int `yaml:"num_clusters"`
ClusteringThreshold float32 `yaml:"clustering_threshold"`
MinDurationOn float32 `yaml:"min_duration_on"`
MinDurationOff float32 `yaml:"min_duration_off"`
}
func (d Diarization) WithDefaults() Diarization {
if d.ModelDir == "" {
d.ModelDir = "./models/diarization"
}
if d.SegmentationModel == "" {
d.SegmentationModel = "pyannote-segmentation-3-0/model.onnx"
}
if d.EmbeddingModel == "" {
d.EmbeddingModel = "3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx"
}
if d.NumThreads <= 0 {
d.NumThreads = 2
}
if d.ClusteringThreshold <= 0 {
d.ClusteringThreshold = 0.5
}
if d.MinDurationOn <= 0 {
d.MinDurationOn = 0.3
}
if d.MinDurationOff <= 0 {
d.MinDurationOff = 0.5
}
return d
}
func (d Diarization) SegmentationPath() string {
return resolveModelPath(d.ModelDir, d.SegmentationModel)
}
func (d Diarization) EmbeddingPath() string {
return resolveModelPath(d.ModelDir, d.EmbeddingModel)
}
func (d Diarization) ModelsPresent() bool {
if _, err := os.Stat(d.SegmentationPath()); err != nil {
return false
}
if _, err := os.Stat(d.EmbeddingPath()); err != nil {
return false
}
return true
}
func resolveModelPath(dir, name string) string {
if filepath.IsAbs(name) {
return name
}
return filepath.Join(dir, name)
}
func (d Diarization) Active() bool {
return d.Enabled
}
+53
View File
@@ -0,0 +1,53 @@
package config
import (
"fmt"
"os"
"runtime"
"gopkg.in/yaml.v3"
)
type File struct {
API API `yaml:"api"`
Transcode Transcode `yaml:"transcode"`
Punctuation Punctuation `yaml:"punctuation"`
Diarization Diarization `yaml:"diarization"`
}
func DefaultFile() File {
return File{
API: API{
Addr: ":8080",
ModelsDir: "./models",
CacheDir: "./cache",
Threads: uint(runtime.NumCPU()),
Language: "ru",
Transcript: Transcript{}.WithDefaults(),
MaxContext: 32,
BeamSize: 5,
EntropyThold: 2.4,
DefaultAsync: true,
Garbage: DefaultGarbage(),
},
Diarization: Diarization{}.WithDefaults(),
Transcode: Transcode{}.WithDefaults(),
Punctuation: Punctuation{Enabled: false, Engine: "off"}.WithDefaults(),
}
}
func LoadFile(path string) (File, error) {
data, err := os.ReadFile(path)
if err != nil {
return File{}, err
}
cfg := DefaultFile()
if err := yaml.Unmarshal(data, &cfg); err != nil {
return File{}, fmt.Errorf("parse config %s: %w", path, err)
}
return cfg, nil
}
func (f File) APIConfig() API {
return f.API
}
+35
View File
@@ -0,0 +1,35 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
err := os.WriteFile(path, []byte(`
api:
addr: ":9090"
models_dir: "/data/models"
language: ru
`), 0o644)
if err != nil {
t.Fatal(err)
}
cfg, err := LoadFile(path)
if err != nil {
t.Fatal(err)
}
if cfg.API.Addr != ":9090" {
t.Fatalf("addr: got %q", cfg.API.Addr)
}
if cfg.API.ModelsDir != "/data/models" {
t.Fatalf("models_dir: got %q", cfg.API.ModelsDir)
}
if cfg.API.Language != "ru" {
t.Fatalf("language: got %q", cfg.API.Language)
}
}
+13
View File
@@ -0,0 +1,13 @@
package config
// DefaultGarbage returns STT artifact tokens filtered from API/CLI transcript output.
func DefaultGarbage() []string {
return []string{"*выбая*"}
}
func (a API) garbagePatterns() []string {
if a.Garbage == nil {
return DefaultGarbage()
}
return a.Garbage
}
+23
View File
@@ -0,0 +1,23 @@
package config
import "testing"
func TestDefaultGarbage(t *testing.T) {
if len(DefaultGarbage()) == 0 || DefaultGarbage()[0] != "*выбая*" {
t.Fatalf("got %v", DefaultGarbage())
}
}
func TestAPI_GarbagePatterns_default(t *testing.T) {
p := (API{}).GarbagePatterns()
if len(p) != 1 || p[0] != "*выбая*" {
t.Fatalf("got %v", p)
}
}
func TestAPI_GarbagePatterns_explicitEmpty(t *testing.T) {
p := (API{Garbage: []string{}}).GarbagePatterns()
if len(p) != 0 {
t.Fatalf("got %v", p)
}
}
+136
View File
@@ -0,0 +1,136 @@
package config
import (
"os"
"github.com/urfave/cli/v2"
)
func LoadResolved(path string) (File, error) {
if path == "" {
if _, err := os.Stat("config.yaml"); err == nil {
path = "config.yaml"
} else {
return DefaultFile(), nil
}
}
return LoadFile(path)
}
func mergeVAD(c *cli.Context, v VAD) VAD {
if c.IsSet("vad") {
v.Enabled = c.Bool("vad")
}
if c.IsSet("vad-model") {
v.Model = c.String("vad-model")
}
if c.IsSet("vad-threshold") {
v.Threshold = c.Float64("vad-threshold")
}
if c.IsSet("vad-min-speech-ms") {
v.MinSpeechMs = c.Int("vad-min-speech-ms")
}
if c.IsSet("vad-min-silence-ms") {
v.MinSilenceMs = c.Int("vad-min-silence-ms")
}
if c.IsSet("vad-max-speech-sec") {
v.MaxSpeechSec = c.Float64("vad-max-speech-sec")
}
if c.IsSet("vad-speech-pad-ms") {
v.SpeechPadMs = c.Int("vad-speech-pad-ms")
}
if c.IsSet("vad-samples-overlap") {
v.SamplesOverlap = c.Float64("vad-samples-overlap")
}
return v.WithDefaults()
}
func mergeAPI(c *cli.Context, a API) API {
if c.IsSet("addr") {
a.Addr = c.String("addr")
}
if c.IsSet("models-dir") {
a.ModelsDir = c.String("models-dir")
}
if c.IsSet("cache-dir") {
a.CacheDir = c.String("cache-dir")
}
if c.IsSet("threads") {
a.Threads = c.Uint("threads")
}
if c.IsSet("language") {
a.Language = c.String("language")
}
if c.IsSet("debug") {
a.Debug = c.Bool("debug")
}
if c.IsSet("speedup") {
a.SpeedUp = c.Bool("speedup")
}
if c.IsSet("translate") {
a.Translate = c.Bool("translate")
}
if c.IsSet("prompt") {
a.Prompt = c.String("prompt")
}
if c.IsSet("max-context") {
a.MaxContext = c.Uint("max-context")
}
if c.IsSet("beam-size") {
a.BeamSize = c.Uint("beam-size")
}
if c.IsSet("entropy-thold") {
a.EntropyThold = c.Float64("entropy-thold")
}
a.VAD = mergeVAD(c, a.VAD)
if c.IsSet("default-punctuation") {
a.DefaultPunctuation = c.Bool("default-punctuation")
}
return a
}
func APIFromCLI(c *cli.Context) (API, error) {
file, err := LoadResolved(c.String("config"))
if err != nil {
return API{}, err
}
return mergeAPI(c, file.API), nil
}
func TranscodeFromCLI(c *cli.Context) (Transcode, error) {
file, err := LoadResolved(c.String("config"))
if err != nil {
return Transcode{}, err
}
return file.Transcode.WithDefaults(), nil
}
func mergePunctuation(c *cli.Context, p Punctuation) Punctuation {
if c.IsSet("punctuation-enabled") {
p.Enabled = c.Bool("punctuation-enabled")
}
if c.IsSet("punctuation-engine") {
p.Engine = c.String("punctuation-engine")
}
if c.IsSet("punctuation-default-on") {
p.DefaultOn = c.Bool("punctuation-default-on")
}
return p.WithDefaults()
}
func PunctuationFromCLI(c *cli.Context) (Punctuation, error) {
file, err := LoadResolved(c.String("config"))
if err != nil {
return Punctuation{}, err
}
return mergePunctuation(c, file.Punctuation), nil
}
func DiarizationFromCLI(c *cli.Context) (Diarization, error) {
file, err := LoadResolved(c.String("config"))
if err != nil {
return Diarization{}, err
}
return file.Diarization.WithDefaults(), nil
}
+28
View File
@@ -0,0 +1,28 @@
package config
import (
"os"
"strings"
"testing"
)
func TestDefaultFile_apiDefaults(t *testing.T) {
f := DefaultFile()
if f.API.Addr == "" {
t.Fatal("expected API listen addr")
}
if f.API.ModelsDir == "" {
t.Fatal("expected models_dir")
}
}
func TestMkdirTemp_usesTmpRoot(t *testing.T) {
dir, err := MkdirTemp("go-whisper-api-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(dir) }()
if !strings.HasPrefix(dir, TempRoot+"/") {
t.Fatalf("temp dir should be under %s, got %s", TempRoot, dir)
}
}
+125
View File
@@ -0,0 +1,125 @@
package config
import (
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
)
func (p Punctuation) XLMJoinSentences() bool {
return p.ApplySBD
}
type Punctuation struct {
Command []string `yaml:"command"`
NumThreads int `yaml:"num_threads"`
TimeoutSec int `yaml:"timeout_sec"`
Engine string `yaml:"engine"`
ModelDir string `yaml:"model_dir"`
ModelFile string `yaml:"model_file"`
SPModel string `yaml:"sp_model"`
ConfigFile string `yaml:"config_file"`
BpeVocab string `yaml:"bpe_vocab"`
HTTPURL string `yaml:"http_url"`
Enabled bool `yaml:"enabled"`
DefaultOn bool `yaml:"default_on"`
ApplySBD bool `yaml:"apply_sbd"`
}
func (p Punctuation) WithDefaults() Punctuation {
if p.Engine == "" {
p.Engine = "heuristic"
}
engine := strings.ToLower(strings.TrimSpace(p.Engine))
if p.ModelDir == "" {
if engine == "xlm" || engine == "xlm-roberta" || engine == "roberta" {
p.ModelDir = "./models/punctuation/xlm-roberta"
} else {
p.ModelDir = "./models/punctuation/ct-transformer-zh-en-int8"
}
}
if p.ModelFile == "" {
if engine == "xlm" || engine == "xlm-roberta" || engine == "roberta" {
p.ModelFile = "model.onnx"
} else {
p.ModelFile = "model.int8.onnx"
}
}
if p.NumThreads <= 0 {
p.NumThreads = 2
}
if p.TimeoutSec <= 0 {
p.TimeoutSec = 120
}
return p
}
func (p Punctuation) Timeout() time.Duration {
p = p.WithDefaults()
return time.Duration(p.TimeoutSec) * time.Second
}
func (p Punctuation) Active() bool {
p = p.WithDefaults()
return p.Enabled && p.Engine != "" && !strings.EqualFold(p.Engine, "off")
}
func (p Punctuation) ModelPath() string {
p = p.WithDefaults()
return filepath.Join(p.ModelDir, p.ModelFile)
}
func (p Punctuation) SPModelPath() string {
p = p.WithDefaults()
if p.SPModel == "" {
return filepath.Join(p.ModelDir, "sp.model")
}
return filepath.Join(p.ModelDir, p.SPModel)
}
func (p Punctuation) XLMConfigPath() string {
p = p.WithDefaults()
if p.ConfigFile == "" {
return filepath.Join(p.ModelDir, "config.yaml")
}
return filepath.Join(p.ModelDir, p.ConfigFile)
}
func (p Punctuation) BpeVocabPath() string {
p = p.WithDefaults()
if p.BpeVocab == "" {
return filepath.Join(p.ModelDir, "bpe.vocab")
}
return filepath.Join(p.ModelDir, p.BpeVocab)
}
func (p Punctuation) ShouldApplyAPI(r *http.Request, apiDefault bool) bool {
if !p.Active() {
return false
}
q := strings.TrimSpace(r.URL.Query().Get("punctuation"))
if q != "" {
return parsePunctuationQuery(q, true)
}
return apiDefault || p.Enabled
}
func parsePunctuationQuery(raw string, def bool) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return def
}
switch strings.ToLower(raw) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
b, err := strconv.ParseBool(raw)
if err != nil {
return def
}
return b
}
+58
View File
@@ -0,0 +1,58 @@
package config
import (
"net/http/httptest"
"testing"
)
func TestPunctuation_Active(t *testing.T) {
if (Punctuation{Enabled: false, Engine: "heuristic"}).Active() {
t.Fatal("disabled should be inactive")
}
if (Punctuation{Enabled: true, Engine: "off"}).Active() {
t.Fatal("engine off should be inactive")
}
if !(Punctuation{Enabled: true, Engine: "heuristic"}).Active() {
t.Fatal("enabled heuristic should be active")
}
}
func TestPunctuation_ShouldApplyAPI(t *testing.T) {
p := Punctuation{Enabled: true, Engine: "heuristic"}
req := httptest.NewRequest("GET", "/?punctuation=1", nil)
if !p.ShouldApplyAPI(req, false) {
t.Fatal("query 1 should enable")
}
req = httptest.NewRequest("GET", "/?punctuation=0", nil)
if p.ShouldApplyAPI(req, true) {
t.Fatal("query 0 should disable")
}
req = httptest.NewRequest("GET", "/", nil)
if !p.ShouldApplyAPI(req, false) {
t.Fatal("enabled in config => apply when query omitted")
}
if !p.ShouldApplyAPI(req, true) {
t.Fatal("api default true")
}
off := Punctuation{Enabled: false, Engine: "heuristic"}
if off.ShouldApplyAPI(req, true) {
t.Fatal("master disabled must never apply")
}
}
func TestParsePunctuationQuery(t *testing.T) {
if !parsePunctuationQuery("1", false) {
t.Fatal("1 => true")
}
if parsePunctuationQuery("0", true) {
t.Fatal("0 => false")
}
if !parsePunctuationQuery("", true) {
t.Fatal("empty => default")
}
}
+9
View File
@@ -0,0 +1,9 @@
package config
import "os"
const TempRoot = "/tmp"
func MkdirTemp(prefix string) (string, error) {
return os.MkdirTemp(TempRoot, prefix)
}
+11
View File
@@ -0,0 +1,11 @@
package config
// Transcode holds audio normalization settings (pure Go decoders; no external ffmpeg).
type Transcode struct {
// FFmpegPath is deprecated and ignored; kept for backward-compatible YAML.
FFmpegPath string `yaml:"ffmpeg_path,omitempty"`
}
func (t Transcode) WithDefaults() Transcode {
return t
}
+26
View File
@@ -0,0 +1,26 @@
package config
import (
"strings"
"time"
)
// Transcript controls how STT segments are joined into one text field (with embedded newlines).
type Transcript struct {
PauseGapSec float64 `yaml:"pause_gap_sec"`
SpeakerLabel string `yaml:"speaker_label"`
}
func (t Transcript) WithDefaults() Transcript {
if t.PauseGapSec <= 0 {
t.PauseGapSec = 1.5
}
if strings.TrimSpace(t.SpeakerLabel) == "" {
t.SpeakerLabel = "Спикер"
}
return t
}
func (t Transcript) PauseGapDuration() time.Duration {
return time.Duration(t.PauseGapSec * float64(time.Second))
}
+88
View File
@@ -0,0 +1,88 @@
package config
import (
"fmt"
"os"
"path/filepath"
)
type VAD struct {
Enabled bool `yaml:"enabled"`
Model string `yaml:"model"`
Threshold float64 `yaml:"threshold"`
MinSpeechMs int `yaml:"min_speech_duration_ms"`
MinSilenceMs int `yaml:"min_silence_duration_ms"`
MaxSpeechSec float64 `yaml:"max_speech_duration_s"`
SpeechPadMs int `yaml:"speech_pad_ms"`
SamplesOverlap float64 `yaml:"samples_overlap"`
}
func DefaultVAD() VAD {
return VAD{
Threshold: 0.5,
MinSpeechMs: 250,
MinSilenceMs: 100,
MaxSpeechSec: 0,
SpeechPadMs: 30,
SamplesOverlap: 0.1,
}
}
func (v VAD) WithDefaults() VAD {
d := DefaultVAD()
if v.Threshold <= 0 {
v.Threshold = d.Threshold
}
if v.MinSpeechMs <= 0 {
v.MinSpeechMs = d.MinSpeechMs
}
if v.MinSilenceMs <= 0 {
v.MinSilenceMs = d.MinSilenceMs
}
if v.SpeechPadMs <= 0 {
v.SpeechPadMs = d.SpeechPadMs
}
if v.SamplesOverlap <= 0 {
v.SamplesOverlap = d.SamplesOverlap
}
return v
}
func (v VAD) ResolveModelPath(modelsDir string) string {
if v.Model == "" {
return ""
}
if filepath.IsAbs(v.Model) {
return v.Model
}
if modelsDir == "" {
return v.Model
}
direct := filepath.Join(modelsDir, v.Model)
if _, err := os.Stat(direct); err == nil {
return direct
}
// VAD weights often live under models_dir/vad/ (not listed in /spr/models).
base := filepath.Base(v.Model)
inVAD := filepath.Join(modelsDir, "vad", base)
if _, err := os.Stat(inVAD); err == nil {
return inVAD
}
return direct
}
func (v VAD) Validate() error {
if !v.Enabled {
return nil
}
if v.Model == "" {
return fmt.Errorf("vad.model is required when vad.enabled is true")
}
if _, err := os.Stat(v.Model); err != nil {
return fmt.Errorf("vad model %q: %w", v.Model, err)
}
if v.Threshold < 0 || v.Threshold > 1 {
return fmt.Errorf("vad.threshold must be between 0 and 1")
}
return nil
}
+65
View File
@@ -0,0 +1,65 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestVAD_Validate_disabled(t *testing.T) {
if err := (VAD{}).Validate(); err != nil {
t.Fatal(err)
}
}
func TestVAD_Validate_requiresModel(t *testing.T) {
err := (VAD{Enabled: true}).Validate()
if err == nil {
t.Fatal("expected error")
}
}
func TestVAD_Validate_modelExists(t *testing.T) {
dir := t.TempDir()
model := filepath.Join(dir, "ggml-silero.bin")
if err := os.WriteFile(model, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
v := VAD{Enabled: true, Model: model, Threshold: 0.5}
if err := v.Validate(); err != nil {
t.Fatal(err)
}
}
func TestVAD_ResolveModelPath(t *testing.T) {
v := VAD{Model: "ggml-silero-v6.2.0.bin"}
got := v.ResolveModelPath("/data/models")
want := filepath.Join("/data/models", "ggml-silero-v6.2.0.bin")
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestVAD_ResolveModelPath_vadSubdir(t *testing.T) {
dir := t.TempDir()
vadDir := filepath.Join(dir, "vad")
if err := os.MkdirAll(vadDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(vadDir, "vad.bin"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
v := VAD{Model: "vad.bin"}
got := v.ResolveModelPath(dir)
want := filepath.Join(dir, "vad", "vad.bin")
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestVAD_WithDefaults(t *testing.T) {
v := VAD{Enabled: true}.WithDefaults()
if v.Threshold != 0.5 || v.MinSpeechMs != 250 || v.MinSilenceMs != 100 {
t.Fatalf("unexpected defaults: %+v", v)
}
}
+43
View File
@@ -0,0 +1,43 @@
package config
import "fmt"
type Whisper struct {
OutputFormat []string `yaml:"output_format"`
Model string `yaml:"model"`
AudioPath string `yaml:"audio_path"`
Language string `yaml:"language"`
Prompt string `yaml:"prompt"`
OutputFolder string `yaml:"output_folder"`
OutputFilename string `yaml:"output_filename"`
Threads uint `yaml:"threads"`
MaxContext uint `yaml:"max_context"`
BeamSize uint `yaml:"beam_size"`
EntropyThold float64 `yaml:"entropy_thold"`
VAD VAD `yaml:"vad"`
Debug bool `yaml:"debug"`
SpeedUp bool `yaml:"speedup"`
Translate bool `yaml:"translate"`
PrintProgress bool `yaml:"print_progress"`
PrintSegment bool `yaml:"print_segment"`
}
func (c *Whisper) ValidateModel() error {
if c.Model == "" {
return fmt.Errorf("model is required")
}
return nil
}
func (c *Whisper) Validate() error {
if err := c.ValidateModel(); err != nil {
return err
}
if c.AudioPath == "" {
return fmt.Errorf("audio path is required")
}
if err := c.VAD.WithDefaults().Validate(); err != nil {
return err
}
return nil
}
+81
View File
@@ -0,0 +1,81 @@
# Metadata for Salama1429/xlm-roberta_punctuation_fullstop_truecase (ONNX punctuation).
# Install into the model directory:
# cp config/xlm-roberta-model.yaml models/punctuation/xlm-roberta/config.yaml
# or: make install-xlm-punctuation-config
languages:
- af
- am
- ar
- bg
- bn
- de
- el
- en
- es
- et
- fa
- fi
- fr
- gu
- hi
- hr
- hu
- id
- is
- it
- ja
- kk
- kn
- ko
- ky
- lt
- lv
- mk
- ml
- mr
- nl
- or
- pa
- pl
- ps
- pt
- ro
- ru
- rw
- so
- sr
- sw
- ta
- te
- tr
- uk
- zh
max_length: 256
pre_labels:
- "<NULL>"
- "¿"
post_labels:
- "<NULL>"
- "<ACRONYM>"
- "."
- ","
- "?"
- ""
- ""
- "。"
- "、"
- "・"
- "।"
- "؟"
- "،"
- ";"
- "።"
- "፣"
- "፧"
null_token: "<NULL>"
acronym_token: "<ACRONYM>"