perf(asr): speed up local Flow dictation and land CLM/keyboard refactor

Reduce perceived latency from key release to final text:
- Adaptive chunking: 2.5s first chunk + 5s follow-ups so short
  utterances start on-device recognition while still recording.
- Session-level ASR warmup and audio-format cache reuse to remove
  per-utterance cold-start of SpeechAnalyzer.
- Mirror live pipelined partials to the keyboard transcript line via
  a new flow.transcriptionPartial App Group key + Darwin ping.

Also commits the accumulated custom language model, Flow session,
keyboard extension restructure, and Xiaomi MiMo provider work in
progress on this branch.
This commit is contained in:
Rocky
2026-07-06 00:00:19 +08:00
parent cfbfb542cc
commit 537a68552a
76 changed files with 3456 additions and 121086 deletions
+41 -95
View File
@@ -1,15 +1,19 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Build OSGKeyboard custom ASR lexicon v1 from Sogou-derived sources.
"""Build OSGKeyboard domain ASR lexicon v1 from the computer-terms scel source.
Sources (experimentation only — Sogou data is non-commercial):
1. ASC8384/SogouPopularDict accumulated pinyin TSV
2. Local 计算机词汇大全【官方推荐】.scel
3. Local 网络流行新词.scel
Sources:
Local 计算机词汇大全【官方推荐】.scel (IT / computer vocabulary only)
Casual network slang and Sogou popular-word dumps are intentionally excluded —
they dilute custom LM phrase biasing without improving domain ASR accuracy.
Output:
OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv
OSGKeyboard/Resources/CustomLanguageModel/v1/manifest.json
The compiled .bin asset is exported separately to
OSGKeyboardShared/Resources/CustomLanguageModel/v1/ via export_clm.swift.
"""
from __future__ import annotations
@@ -17,43 +21,19 @@ from __future__ import annotations
import argparse
import json
import sys
import urllib.request
from collections import Counter
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from scel_parser import get_scel_info, load_pinyin_tsv, parse_scel_file
from scel_parser import get_scel_info, parse_scel_file
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_OUTPUT_DIR = REPO_ROOT / "OSGKeyboard/Resources/CustomLanguageModel/v1"
SOGOU_ACCUMULATED_URL = (
"https://raw.githubusercontent.com/ASC8384/SogouPopularDict/main/"
"data/sogou_network_words_accumulated_pinyin.tsv"
)
DEFAULT_COMPUTER_SCEL = Path("/Users/rocky/Downloads/计算机词汇大全【官方推荐】.scel")
DEFAULT_NETWORK_SCEL = Path("/Users/rocky/Downloads/网络流行新词.scel")
@dataclass(frozen=True)
class SourceSpec:
key: str
label: str
weight: int
SOURCES = [
SourceSpec("computer_terms", "计算机词汇大全【官方推荐】", weight=5),
SourceSpec("network_slang_local", "网络流行新词.scel", weight=3),
SourceSpec("sogou_popular_accumulated", "SogouPopularDict accumulated", weight=1),
]
def download_accumulated_tsv(destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
with urllib.request.urlopen(SOGOU_ACCUMULATED_URL, timeout=120) as response:
destination.write_bytes(response.read())
SOURCE_KEY = "computer_terms"
SOURCE_LABEL = "计算机词汇大全【官方推荐】"
SOURCE_WEIGHT = 5
@dataclass
@@ -64,24 +44,26 @@ class LexiconEntry:
weight: int
def merge_entries(sources: list[tuple[SourceSpec, list[tuple[str, str]]]]) -> list[LexiconEntry]:
def merge_entries(entries: list[tuple[str, str]]) -> list[LexiconEntry]:
merged: dict[str, LexiconEntry] = {}
source_counts: Counter[str] = Counter()
for spec, entries in sources:
for word, pinyin in entries:
source_counts[spec.key] += 1
current = merged.get(word)
candidate = LexiconEntry(word=word, pinyin=pinyin, source=spec.key, weight=spec.weight)
if current is None or candidate.weight > current.weight:
merged[word] = candidate
elif current.weight == candidate.weight and not current.pinyin and pinyin:
merged[word] = candidate
for word, pinyin in entries:
current = merged.get(word)
candidate = LexiconEntry(
word=word,
pinyin=pinyin,
source=SOURCE_KEY,
weight=SOURCE_WEIGHT,
)
if current is None:
merged[word] = candidate
elif not current.pinyin and pinyin:
merged[word] = candidate
return sorted(merged.values(), key=lambda item: (item.weight * -1, item.word))
return sorted(merged.values(), key=lambda item: item.word)
def write_outputs(entries: list[LexiconEntry], output_dir: Path, source_stats: dict[str, int]) -> None:
def write_outputs(entries: list[LexiconEntry], output_dir: Path, raw_count: int) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
phrases_path = output_dir / "phrases.tsv"
@@ -97,17 +79,16 @@ def write_outputs(entries: list[LexiconEntry], output_dir: Path, source_stats: d
"entry_count": len(entries),
"sources": [
{
"key": spec.key,
"label": spec.label,
"weight": spec.weight,
"raw_count": source_stats.get(spec.key, 0),
"key": SOURCE_KEY,
"label": SOURCE_LABEL,
"weight": SOURCE_WEIGHT,
"raw_count": raw_count,
}
for spec in SOURCES
],
"notes": [
"Sogou-derived data is for internal ASR experimentation only.",
"Domain-specific computer/IT vocabulary only; casual network slang removed.",
"PhraseCount weights map to SFCustomLanguageModelData relative frequencies.",
"Higher source weight wins on duplicate words.",
"Merged with ai-tech-brands seed at export time for the final .bin asset.",
],
"files": {
"phrases": phrases_path.name,
@@ -118,47 +99,19 @@ def write_outputs(entries: list[LexiconEntry], output_dir: Path, source_stats: d
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def build(
*,
computer_scel: Path,
network_scel: Path,
output_dir: Path,
skip_download: bool,
) -> int:
cache_dir = REPO_ROOT / ".cache/lexicon"
cache_dir.mkdir(parents=True, exist_ok=True)
accumulated_tsv = cache_dir / "sogou_network_words_accumulated_pinyin.tsv"
if not skip_download and not accumulated_tsv.exists():
print(f"Downloading {SOGOU_ACCUMULATED_URL}")
download_accumulated_tsv(accumulated_tsv)
elif not accumulated_tsv.exists():
print(f"Missing accumulated TSV: {accumulated_tsv}", file=sys.stderr)
return 1
def build(*, computer_scel: Path, output_dir: Path) -> int:
if not computer_scel.exists():
print(f"Missing computer scel: {computer_scel}", file=sys.stderr)
return 1
if not network_scel.exists():
print(f"Missing network scel: {network_scel}", file=sys.stderr)
return 1
computer_info = get_scel_info(computer_scel)
network_info = get_scel_info(network_scel)
print(f"Computer dict: {computer_info.name} ({computer_info.word_count} header count)")
print(f"Network dict: {network_info.name} ({network_info.word_count} header count)")
loaded_sources: list[tuple[SourceSpec, list[tuple[str, str]]]] = [
(SOURCES[0], parse_scel_file(computer_scel)),
(SOURCES[1], parse_scel_file(network_scel)),
(SOURCES[2], load_pinyin_tsv(accumulated_tsv)),
]
raw_entries = parse_scel_file(computer_scel)
merged = merge_entries(raw_entries)
write_outputs(merged, output_dir, raw_count=len(raw_entries))
source_stats = {spec.key: len(entries) for spec, entries in loaded_sources}
merged = merge_entries(loaded_sources)
write_outputs(merged, output_dir, source_stats)
print(f"Raw counts: {source_stats}")
print(f"Raw count: {len(raw_entries)}")
print(f"Merged unique entries: {len(merged)}")
print(f"Wrote {output_dir / 'phrases.tsv'}")
print(f"Wrote {output_dir / 'manifest.json'}")
@@ -166,18 +119,11 @@ def build(
def main() -> int:
parser = argparse.ArgumentParser(description="Build OSGKeyboard custom ASR lexicon v1")
parser = argparse.ArgumentParser(description="Build OSGKeyboard domain ASR lexicon v1")
parser.add_argument("--computer-scel", type=Path, default=DEFAULT_COMPUTER_SCEL)
parser.add_argument("--network-scel", type=Path, default=DEFAULT_NETWORK_SCEL)
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
parser.add_argument("--skip-download", action="store_true")
args = parser.parse_args()
return build(
computer_scel=args.computer_scel,
network_scel=args.network_scel,
output_dir=args.output_dir,
skip_download=args.skip_download,
)
return build(computer_scel=args.computer_scel, output_dir=args.output_dir)
if __name__ == "__main__":