Skip to content

Commit 9133f89

Browse files
committed
release: v0.6.9 — deterministic Simplified Chinese output
- Add deterministic Traditional→Simplified post-processor backed by OpenCC's TSCharacters mapping (4,105 entries, Apache-2.0). Guarantees Simplified output regardless of what Whisper or the reasoning model emits, instead of relying on prompt instructions. - Capture the language Whisper.cpp auto-detects (stderr 'auto-detected language: <code>') and what OpenAI/Groq report via verbose_json's 'language' field. Return it to the frontend as TranscriptionResult { text, detected_language } and forward it into the AI enhancement call so auto mode runs language-aware processing instead of falling back to the kana heuristic. - New entry point finalize_chinese_text(text, language) consolidates the punctuation (existing) and Traditional→Simplified (new) passes; called from transcribe_local, transcribe_cloud, and process_reasoning. - Fix t2s_table module path (declared in transcription/mod.rs as private sibling) and trim normalize re-exports to finalize_chinese_text only. - 110 lib tests pass (41 new for T→S + detected-language plumbing); no new clippy warnings introduced.
1 parent f3b9d68 commit 9133f89

17 files changed

Lines changed: 1664 additions & 46 deletions

docs/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## [0.6.9] - 2026-05-15
4+
5+
### Fixes
6+
7+
- Fixed Chinese output occasionally containing Traditional characters (繁體) instead of Simplified (简体), even when the Chinese language is selected and the prompts explicitly require Simplified — added a deterministic Traditional→Simplified post-processor backed by OpenCC's character mapping (4,105 entries, Apache-2.0), so the output is guaranteed regardless of what the Whisper or reasoning model emits. Runs whenever the user selects any Chinese variant (`zh`, `zh-CN`, `zh-TW`, `zh-HK`, …) and, in auto-detect mode, only when Han characters are present AND no kana (so Japanese kanji like 馬 stay as 馬, not 马)
8+
- New entry point `transcription::finalize_chinese_text(text, language)` consolidates the two passes — punctuation normalization (existing) + Traditional→Simplified (new) — and replaces the previous direct calls to `normalize_cjk_punctuation` from `transcribe_local`, `transcribe_cloud`, and `process_reasoning`. The reasoning command now accepts a `language` parameter forwarded from `useTranscriptionPipeline` so the deterministic safety net runs on the enhancement output too, not just the raw transcription
9+
- Fixed auto-detect mode relying on a kana heuristic to decide whether output is Chinese vs. Japanese — now reads the actual detected language from the transcription model. Whisper.cpp's stderr is parsed for `auto-detected language: <code>`, and OpenAI/Groq are switched to `response_format=verbose_json` so their response includes a `language` field. The detected code is returned to the frontend in a new `TranscriptionResult { text, detected_language }` shape and forwarded into the subsequent AI enhancement call so language-aware prompts and T→S enforcement run with the resolved language instead of "auto". User-explicit language choices still win — detection only fills in when the user picked auto
10+
11+
### Internal
12+
13+
- Added `src-tauri/src/transcription/t2s_table.rs` — auto-generated, sorted slice of `(char, char)` pairs used by `to_simplified_char` via binary search (~12 comparisons per lookup, ~33 KB binary size)
14+
- Added `scripts/gen_t2s_table.py` to regenerate the table from OpenCC's `TSCharacters.txt`; never hand-edit the table
15+
- 31 new Rust unit tests in `normalize.rs` covering: T→S character mapping (common chars, already-Simplified no-op, ASCII/kana pass-through), `convert_to_simplified` idempotency and mixed-input handling, `is_chinese_language` variant acceptance (`zh`, `zh-CN`, `zh_TW`, `ZH`), auto-detect gating (kana detection blocks T→S conversion), full pipeline `finalize_chinese_text` for zh/ja/en/auto
16+
- Added `WhisperOutput { text, detected_language }` and `CloudTranscription { text, detected_language }` Rust structs replacing bare `String` returns from the transcription layer. `normalize_provider_language` converts OpenAI's full-name responses ("english", "chinese") back to ISO codes
17+
- 10 additional Rust unit tests: `parse_detected_language` (Chinese/English/subtag/missing/repeated), `normalize_provider_language` (ISO pass-through, full-name → code, unknown fall-through, empty/whitespace), `effective_language` (explicit choice wins, auto uses detection, no-detection → None). 81 transcription-module tests pass total; clippy clean
18+
319
## [0.6.8] - 2026-04-24
420

521
### Fixes

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "whisperi",
33
"private": true,
4-
"version": "0.6.8",
4+
"version": "0.6.9",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

scripts/gen_t2s_table.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Generate src-tauri/src/transcription/t2s_table.rs from OpenCC's
4+
TSCharacters.txt mapping (Apache-2.0 licensed).
5+
6+
Usage:
7+
python3 scripts/gen_t2s_table.py [--source URL_OR_PATH]
8+
9+
By default fetches the latest data from BYVoid/OpenCC on GitHub. Pass a local
10+
path to use a cached copy. The resulting Rust file contains a sorted slice of
11+
`(char, char)` pairs used by `convert_to_simplified()` via binary search.
12+
13+
This script is the single source of truth for the conversion table — do not
14+
hand-edit `t2s_table.rs`. Re-run this whenever you want to refresh the mapping.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import argparse
20+
import os
21+
import sys
22+
from urllib.request import urlopen
23+
24+
DEFAULT_SOURCE = (
25+
"https://raw.githubusercontent.com/BYVoid/OpenCC/master/data/dictionary/TSCharacters.txt"
26+
)
27+
OUTPUT_PATH = os.path.join(
28+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
29+
"src-tauri",
30+
"src",
31+
"transcription",
32+
"t2s_table.rs",
33+
)
34+
35+
36+
def read_source(source: str) -> str:
37+
if source.startswith(("http://", "https://")):
38+
with urlopen(source) as resp:
39+
return resp.read().decode("utf-8")
40+
with open(source, "r", encoding="utf-8") as fh:
41+
return fh.read()
42+
43+
44+
def parse_pairs(text: str) -> list[tuple[int, int]]:
45+
pairs: list[tuple[int, int]] = []
46+
for line in text.splitlines():
47+
line = line.rstrip("\n")
48+
if not line or line.startswith("#"):
49+
continue
50+
parts = line.split("\t")
51+
if len(parts) < 2:
52+
continue
53+
key = parts[0]
54+
targets = parts[1].split(" ")
55+
target = targets[0]
56+
if not key or not target:
57+
continue
58+
if len(key) != 1 or len(target) != 1:
59+
# Multi-codepoint sources/targets are rare; keep the first char only
60+
if len(target) > 1:
61+
target = target[0]
62+
if len(key) != 1:
63+
continue
64+
if key == target:
65+
continue
66+
pairs.append((ord(key), ord(target)))
67+
68+
# Sort and dedupe by source
69+
pairs.sort(key=lambda p: p[0])
70+
seen: set[int] = set()
71+
unique: list[tuple[int, int]] = []
72+
for p in pairs:
73+
if p[0] in seen:
74+
continue
75+
seen.add(p[0])
76+
unique.append(p)
77+
return unique
78+
79+
80+
def emit_rust(pairs: list[tuple[int, int]]) -> str:
81+
out: list[str] = []
82+
out.append("// Auto-generated from OpenCC TSCharacters.txt (Apache-2.0 License).")
83+
out.append(
84+
"// Source: https://github.com/BYVoid/OpenCC/blob/master/data/dictionary/TSCharacters.txt"
85+
)
86+
out.append("// DO NOT EDIT BY HAND. Regenerate via scripts/gen_t2s_table.py.")
87+
out.append("//")
88+
out.append(f"// Total entries: {len(pairs)}")
89+
out.append("// Sorted by Traditional (source) codepoint to enable binary search.")
90+
out.append("")
91+
out.append("pub(super) static T2S_TABLE: &[(char, char)] = &[")
92+
93+
per_line = 6
94+
buf: list[str] = []
95+
for src, dst in pairs:
96+
buf.append(f"('\\u{{{src:X}}}', '\\u{{{dst:X}}}')")
97+
if len(buf) == per_line:
98+
out.append(" " + ", ".join(buf) + ",")
99+
buf = []
100+
if buf:
101+
out.append(" " + ", ".join(buf) + ",")
102+
out.append("];")
103+
out.append("")
104+
return "\n".join(out)
105+
106+
107+
def main() -> int:
108+
ap = argparse.ArgumentParser(description=__doc__)
109+
ap.add_argument(
110+
"--source",
111+
default=DEFAULT_SOURCE,
112+
help="URL or local path to OpenCC TSCharacters.txt",
113+
)
114+
ap.add_argument("--output", default=OUTPUT_PATH)
115+
args = ap.parse_args()
116+
117+
print(f"Reading source: {args.source}", file=sys.stderr)
118+
raw = read_source(args.source)
119+
pairs = parse_pairs(raw)
120+
if not pairs:
121+
print("No pairs parsed — aborting", file=sys.stderr)
122+
return 1
123+
124+
rust = emit_rust(pairs)
125+
with open(args.output, "w", encoding="utf-8") as fh:
126+
fh.write(rust)
127+
print(
128+
f"Wrote {len(pairs)} entries to {args.output}",
129+
file=sys.stderr,
130+
)
131+
return 0
132+
133+
134+
if __name__ == "__main__":
135+
raise SystemExit(main())

scripts/release-v0.6.9.sh

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#!/usr/bin/env bash
2+
# One-shot release script for v0.6.9.
3+
# Run from the repo root: `bash scripts/release-v0.6.9.sh`
4+
# Or in PowerShell: `bash scripts/release-v0.6.9.sh`
5+
#
6+
# What it does:
7+
# 1. Stages ONLY the files this change touched (avoids 100+ unrelated
8+
# CRLF→LF line-ending diffs that the editor environment introduced).
9+
# 2. Commits with a clear release message.
10+
# 3. Creates an annotated v0.6.9 tag.
11+
# 4. Pushes the commit and tag — pushing the `v*` tag triggers
12+
# .github/workflows/release.yml, which builds and publishes the
13+
# Windows installer.
14+
#
15+
# Safe to re-run: it bails out if the tag already exists.
16+
17+
set -euo pipefail
18+
19+
cd "$(dirname "$0")/.."
20+
21+
if git rev-parse v0.6.9 >/dev/null 2>&1; then
22+
echo "Tag v0.6.9 already exists — aborting." >&2
23+
exit 1
24+
fi
25+
26+
# Sanity-check the version bumps actually landed.
27+
grep -q '"version": "0.6.9"' package.json || {
28+
echo "package.json not at 0.6.9 — aborting." >&2; exit 1
29+
}
30+
grep -q '^version = "0.6.9"' src-tauri/Cargo.toml || {
31+
echo "src-tauri/Cargo.toml not at 0.6.9 — aborting." >&2; exit 1
32+
}
33+
grep -q '"version": "0.6.9"' src-tauri/tauri.conf.json || {
34+
echo "src-tauri/tauri.conf.json not at 0.6.9 — aborting." >&2; exit 1
35+
}
36+
grep -q '^## \[0.6.9\]' docs/CHANGELOG.md || {
37+
echo "docs/CHANGELOG.md missing [0.6.9] heading — aborting." >&2; exit 1
38+
}
39+
40+
# Stage only the files this change actually touched. The working tree has
41+
# many unrelated `M` entries that are pure line-ending differences from the
42+
# editor sandbox; deliberately not adding those.
43+
git add \
44+
package.json \
45+
src-tauri/Cargo.toml \
46+
src-tauri/tauri.conf.json \
47+
docs/CHANGELOG.md \
48+
scripts/gen_t2s_table.py \
49+
scripts/release-v0.6.9.sh \
50+
src-tauri/src/transcription/normalize.rs \
51+
src-tauri/src/transcription/t2s_table.rs \
52+
src-tauri/src/transcription/mod.rs \
53+
src-tauri/src/transcription/cloud.rs \
54+
src-tauri/src/transcription/whisper.rs \
55+
src-tauri/src/commands/transcription.rs \
56+
src-tauri/src/commands/reasoning.rs \
57+
src/services/tauriApi.ts \
58+
src/hooks/useTranscriptionPipeline.ts \
59+
src/hooks/useAudioRecording.ts
60+
61+
echo "--- staged for v0.6.9 ---"
62+
git diff --cached --stat
63+
64+
git commit -m "release: v0.6.9 — deterministic Simplified Chinese output
65+
66+
- Add deterministic Traditional→Simplified post-processor backed by
67+
OpenCC's TSCharacters mapping (4,105 entries, Apache-2.0). Guarantees
68+
Simplified output regardless of what Whisper or the reasoning model
69+
emits, instead of relying on prompt instructions.
70+
- Capture the language Whisper.cpp auto-detects (stderr 'auto-detected
71+
language: <code>') and what OpenAI/Groq report via verbose_json's
72+
'language' field. Return it to the frontend as
73+
TranscriptionResult { text, detected_language } and forward it into
74+
the AI enhancement call so auto mode runs language-aware processing
75+
instead of falling back to the kana heuristic.
76+
- New entry point finalize_chinese_text(text, language) consolidates the
77+
punctuation (existing) and Traditional→Simplified (new) passes; called
78+
from transcribe_local, transcribe_cloud, and process_reasoning.
79+
- 41 new Rust unit tests (81 transcription-module total); clippy clean."
80+
81+
git tag -a v0.6.9 -m "v0.6.9 — deterministic Simplified Chinese output"
82+
83+
git push origin HEAD
84+
git push origin v0.6.9
85+
86+
echo
87+
echo "✓ Pushed v0.6.9. Watch the build at:"
88+
echo " https://github.com/xarthurx/whisperi/actions/workflows/release.yml"

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "whisperi"
3-
version = "0.6.8"
3+
version = "0.6.9"
44
edition = "2024"
55
description = "Fast desktop dictation powered by whisper.cpp"
66

src-tauri/src/commands/reasoning.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::reasoning::{self, ReasoningRequest};
22

33
#[tauri::command]
4+
#[allow(clippy::too_many_arguments)]
45
pub async fn process_reasoning(
56
text: String,
67
model: String,
@@ -9,6 +10,7 @@ pub async fn process_reasoning(
910
api_key: String,
1011
max_tokens: Option<u32>,
1112
temperature: Option<f64>,
13+
language: Option<String>,
1214
) -> Result<String, String> {
1315
let key_preview = if api_key.len() > 8 {
1416
format!("{}...{}", &api_key[..4], &api_key[api_key.len()-4..])
@@ -30,7 +32,14 @@ pub async fn process_reasoning(
3032
match reasoning::process(&req).await {
3133
Ok(response) => {
3234
log::info!("[Whisperi] Enhancement complete ({} chars)", response.text.len());
33-
Ok(crate::transcription::normalize_cjk_punctuation(&response.text))
35+
// Run the full Chinese post-processing pipeline (punctuation + T→S
36+
// when the configured language is Chinese). Acts as a deterministic
37+
// safety net for models that occasionally slip into Traditional
38+
// characters or half-width punctuation despite prompt instructions.
39+
Ok(crate::transcription::finalize_chinese_text(
40+
&response.text,
41+
language.as_deref(),
42+
))
3443
}
3544
Err(e) => {
3645
log::error!("[Whisperi] Enhancement failed: {}", e);

0 commit comments

Comments
 (0)