Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ AirTranslateは、Macで再生されている音声をリアルタイムで文
- **より安定したフローティング翻訳:** 新しい翻訳が用意できるまで前の翻訳を保持し、認識器が文を直すたびに字幕が書き換わったり点滅したりしません。
- **予約された字幕の高さ:** フローティング字幕は固定ブロック高さを保ち、差し替えを一塊でフェードするため、文字が増えても原文行が上下に跳ねたり中央揃えし直したりしません。
- **字幕の安定化と配置:** 設定とメニューバーで字幕の安定化(即応/標準/安定)と字幕の配置(中央/左)を選べます。左揃えは行が伸びても始点が固定されます。
- **プレゼンテーション品質モード:** 1つのスイッチで、翻訳のみ・2行・安定した節のタイミングを使う観客向けオーバーレイを設定できます。講演の背景と用語集を追加すると、人名、ブランド名、略語、希望する日本語表現を正確に保てます。

詳細は[AirTranslate 1.7.1リリースノート](https://github.com/himomohi/AirTranslate/releases/tag/v1.7.1)をご覧ください。

Expand Down
1 change: 1 addition & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ AirTranslate는 Mac에서 재생되는 소리를 실시간으로 기록하고
- **더 안정적인 플로팅 번역:** 새 번역이 준비될 때까지 이전 번역을 유지해, 인식기가 문장을 고칠 때마다 자막이 다시 쓰이거나 깜빡이지 않습니다.
- **예약된 자막 높이:** 플로팅 자막이 고정 블록 높이를 유지하고 교체 문구를 한 덩어리로 페이드해, 글자가 늘어나도 원문 줄이 위아래로 점프하거나 다시 가운데 맞춰지지 않습니다.
- **자막 안정화와 정렬:** 설정과 메뉴 막대에서 자막 안정화(빠름/기본/안정)와 자막 정렬(가운데/왼쪽)을 고를 수 있습니다. 왼쪽 정렬은 줄이 늘어나도 시작점이 고정됩니다.
- **프레젠테이션 품질 모드:** 스위치 하나로 번역만 표시되는 두 줄 청중용 오버레이와 더 안정적인 구절 타이밍을 설정합니다. 발표 맥락과 용어집을 추가해 이름, 브랜드, 약어, 선호 번역을 정확히 유지할 수 있습니다.

전체 내용은 [AirTranslate 1.7.1 릴리즈 노트](https://github.com/himomohi/AirTranslate/releases/tag/v1.7.1)에서 확인할 수 있습니다.

Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ The default workflow uses Apple frameworks. GPT Realtime, Gemini Live Translate,
- **Steadier floating translations:** The overlay holds the previous translation until a new one is ready, so live captions no longer rewrite or flicker on every recognizer revision.
- **Reserved caption height:** Floating captions keep a fixed block height and fade replacements in one piece, so the source line does not jump or re-center as text grows.
- **Caption Stability and alignment:** Settings and the menu bar now offer Caption Stability (Responsive / Balanced / Steady) and Caption Alignment (Center / Left). Left-aligned captions stay anchored as the line grows.
- **Presentation Quality mode:** One switch configures a translation-only, two-line audience overlay with steadier clause timing. Add talk context and a terminology glossary to protect names, brands, acronyms, and preferred Japanese wording.

See the complete [AirTranslate 1.7.1 release notes](https://github.com/himomohi/AirTranslate/releases/tag/v1.7.1).

Expand Down Expand Up @@ -248,6 +249,32 @@ Developer ID signing and notarization are planned for a later distribution step.

## Build From Source

### Install the privacy-fixed custom build on another Mac

On an Apple Silicon Mac running macOS 26 or later, paste this block into Terminal. It downloads an immutable installer revision and verifies its SHA-256 digest before execution:

```bash
INSTALLER_COMMIT="2cffcdde08e7d9d4cb7408aa1c34e08df8a753a2"
INSTALLER_SHA256="49ea3e8b1b6fcd2df4adf8e649af12ef634baf471facf611d746c2866e4469f7"
INSTALLER_PATH="$(mktemp "${TMPDIR:-/tmp}/airtranslate-installer.XXXXXX")"
curl -fL "https://raw.githubusercontent.com/gary8020/AirTranslate/$INSTALLER_COMMIT/script/install_on_this_mac.sh" -o "$INSTALLER_PATH"
printf '%s %s\n' "$INSTALLER_SHA256" "$INSTALLER_PATH" | shasum -a 256 -c -
AIRTRANSLATE_SOURCE_REVISION="$INSTALLER_COMMIT" /bin/bash "$INSTALLER_PATH" install
rm -f "$INSTALLER_PATH"
```

The installer downloads the pinned source commit, creates a release build locally, checks
the app bundle's code-signature integrity, installs it to
`~/Applications/AirTranslate.app`, and opens it. It does not copy API keys,
transcripts, or settings from another Mac. Each Mac must grant its own
Microphone and Speech Recognition permissions and download any required Apple
language assets. macOS may ask for those permissions again after an update if
the Mac does not have a persistent code-signing identity.

Running a newer verified installer block updates the source and preserves one previous app at
`~/Library/Application Support/AirTranslate Custom Build/backups/AirTranslate-previous.app`
before installing the new build.

Run the app bundle:

```bash
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ AirTranslate 可以捕获 Mac 正在播放的音频,实时转写并翻译,
- **更稳定的悬浮翻译:** 在新译文就绪之前会保留上一句翻译,识别器每次修订句子时字幕不再整句重写或闪烁。
- **预留字幕高度:** 悬浮字幕保持固定块高度,并以整块淡入替换文本,因此原文行不会在文字变长时上下跳动或重新居中。
- **字幕稳定与对齐:** 可在设置和菜单栏选择字幕稳定(灵敏/均衡/稳定)和字幕对齐(居中/左)。左对齐会在行变长时保持起点固定。
- **演示质量模式:** 一键设置仅显示译文的两行观众字幕,并采用更稳定的分句时机。可添加演讲背景和术语表,以准确保留人名、品牌、缩写及首选译法。

完整内容请参阅 [AirTranslate 1.7.1 发布说明](https://github.com/himomohi/AirTranslate/releases/tag/v1.7.1)。

Expand Down
3 changes: 2 additions & 1 deletion Release/GITHUB-RELEASE-1.7.1.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# AirTranslate 1.7.1

AirTranslate 1.7.1 steadies the floating caption overlay so live translations stop jumping, flickering, and rewriting mid-sentence, and adds Caption Stability and Caption Alignment controls.
AirTranslate 1.7.1 steadies the floating caption overlay so live translations stop jumping, flickering, and rewriting mid-sentence, adds Caption Stability and Caption Alignment controls, and introduces a Presentation Quality mode for audience-facing interpretation.

AirTranslate is an independent open-source project and is not affiliated with Apple, OpenAI, Google, or Meta.

## Added

- **Caption Stability** (Responsive / Balanced / Steady) and **Caption Alignment** (Center / Left) are available in Settings and the menu bar. Steady holds each rewrite longer; Left keeps the start of each line fixed as text grows.
- **Presentation Quality mode** switches to a translation-only, two-line audience overlay with steadier clause timing. Optional talk context improves GPT text translation, while the terminology glossary protects names, brands, acronyms, and preferred translations across caption engines.

## Changed

Expand Down
3 changes: 2 additions & 1 deletion Release/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Release/product/AirTranslate-<version>.dmg.sha256
## 1.7.1 Floating Caption Stability Notes

- Public docs must describe Caption Stability (Responsive / Balanced / Steady) and Caption Alignment (Center / Left) as Settings and menu-bar controls for the floating overlay.
- Public docs must describe Presentation Quality mode, talk context, and the terminology glossary as optional audience-caption controls.
- Floating translations hold the previous translation until a replacement is ready; do not describe the overlay as clearing to a blank line on every rewrite.
- Floating captions reserve a fixed caption height so the source line does not jump when the translation appears or wraps.

Expand Down Expand Up @@ -97,5 +98,5 @@ git diff -- . ':(exclude).build/**' ':(exclude)Release/product/**' | \
- Confirm Gemini Live mode still requires a user-provided key at runtime and does not bundle one.
- Confirm Meta Scribe mode still requires a user-provided key at runtime and does not bundle one.
- Confirm `Release/product/` remains ignored.
- Confirm all four public READMEs and `GITHUB-RELEASE-1.7.1.md` describe all three public themes with equivalent meaning: steadier floating translations that hold the previous line, reserved caption height, and Caption Stability plus Caption Alignment controls.
- Confirm all four public READMEs and `GITHUB-RELEASE-1.7.1.md` describe all four public themes with equivalent meaning: steadier floating translations that hold the previous line, reserved caption height, Caption Stability plus Caption Alignment controls, and Presentation Quality mode with terminology guidance.
- Publish the new GitHub Release without deleting previous release versions or tags.
201 changes: 201 additions & 0 deletions Sources/AirTranslate/Models/TranslationQualityContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import Foundation

struct TranslationGlossaryEntry: Equatable, Sendable {
let source: String
let target: String
}

struct TranslationQualityContext: Equatable, Sendable {
static let maximumPresentationContextCharacters = 1_000
static let maximumGlossaryEntries = 50
static let maximumGlossaryTermCharacters = 120
private static let separators = ["=>", "->", "→", "="]

let presentationContext: String
let glossaryEntries: [TranslationGlossaryEntry]
private let replacementEntries: [TranslationGlossaryEntry]

init(presentationContext: String, glossaryText: String) {
self.presentationContext = String(
presentationContext
.trimmingCharacters(in: .whitespacesAndNewlines)
.prefix(Self.maximumPresentationContextCharacters)
)
let parsedEntries = Self.parseGlossary(glossaryText)
glossaryEntries = parsedEntries
replacementEntries = parsedEntries.sorted { left, right in
left.source.count > right.source.count
}
}

var sourceTerms: [String] {
glossaryEntries.map(\.source)
}

func applyingTerminology(to text: String) -> String {
let matches = replacementEntries
.flatMap { entry in
matchingRanges(for: entry, in: text).map { range in
(range: range, entry: entry)
}
}
.sorted { left, right in
if left.range.lowerBound == right.range.lowerBound {
return left.entry.source.count > right.entry.source.count
}
return left.range.lowerBound < right.range.lowerBound
}

guard !matches.isEmpty else { return text }

var result = ""
var cursor = text.startIndex
for match in matches where match.range.lowerBound >= cursor {
result.append(contentsOf: text[cursor..<match.range.lowerBound])
result.append(match.entry.target)
cursor = match.range.upperBound
}
result.append(contentsOf: text[cursor...])
return result
}

func enhancing(instructions baseInstructions: String, target: LanguageOption) -> String {
var guidance = [
baseInstructions,
"This is live simultaneous interpretation for an audience. Translate the speaker's meaning naturally and concisely instead of following source-language word order.",
"Preserve names, numbers, acronyms, and business terminology. Omit empty fillers such as uh, um, and huh when they add no meaning.",
]

if target.id.lowercased().hasPrefix("ja") {
guidance.append("Use polite, natural Japanese suitable for projected captions, with concise です/ます style where appropriate.")
}

if !presentationContext.isEmpty {
guidance.append("Presentation context: \(presentationContext)")
}

if !glossaryEntries.isEmpty {
let terms = glossaryEntries
.map { "- \($0.source) => \($0.target)" }
.joined(separator: "\n")
guidance.append("Use these exact target terms whenever the matching source term appears:\n\(terms)")
}

guidance.append("Return only the interpretation. Do not add explanations, labels, or facts that the speaker did not say.")
return guidance.joined(separator: "\n\n")
}

private static func parseGlossary(_ text: String) -> [TranslationGlossaryEntry] {
var entries: [TranslationGlossaryEntry] = []
var indexBySource: [String: Int] = [:]

for rawLine in text.components(separatedBy: .newlines) {
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
guard !line.isEmpty, !line.hasPrefix("#") else { continue }
guard let separatorRange = separators
.compactMap({ separator in
line.range(of: separator).map { (range: $0, length: separator.count) }
})
.min(by: { left, right in
if left.range.lowerBound == right.range.lowerBound {
return left.length > right.length
}
return left.range.lowerBound < right.range.lowerBound
})?
.range
else { continue }

let source = line[..<separatorRange.lowerBound]
.trimmingCharacters(in: .whitespacesAndNewlines)
let target = line[separatorRange.upperBound...]
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !source.isEmpty, !target.isEmpty else { continue }
guard source.count <= maximumGlossaryTermCharacters,
target.count <= maximumGlossaryTermCharacters
else { continue }

let entry = TranslationGlossaryEntry(source: source, target: target)
let normalizedSource = source.folding(
options: [.caseInsensitive, .diacriticInsensitive],
locale: .current
)
if let existingIndex = indexBySource[normalizedSource] {
entries[existingIndex] = entry
} else {
indexBySource[normalizedSource] = entries.count
entries.append(entry)
}

if entries.count >= maximumGlossaryEntries {
break
}
}

return entries
}

private func matchingRanges(
for entry: TranslationGlossaryEntry,
in text: String
) -> [Range<String.Index>] {
var ranges: [Range<String.Index>] = []
var searchStart = text.startIndex

while searchStart < text.endIndex,
let range = text.range(
of: entry.source,
options: [.caseInsensitive],
range: searchStart..<text.endIndex
) {
if Self.hasTermBoundaries(range, in: text) {
ranges.append(range)
}
searchStart = range.upperBound
}

return ranges
}

private static func hasTermBoundaries(
_ range: Range<String.Index>,
in text: String
) -> Bool {
if range.lowerBound > text.startIndex {
let previous = text[text.index(before: range.lowerBound)]
if previous.isLetter || previous.isNumber {
return false
}
}

if range.upperBound < text.endIndex {
let next = text[range.upperBound]
if next.isLetter || next.isNumber {
return false
}
}

return true
}
}

enum TranslationQualityPolicy {
/// Returns nil when the normal low-latency policy should remain in charge.
static func debounceDelay(
isEnabled: Bool,
isFinal: Bool,
sourceText: String
) -> Int? {
guard isEnabled else { return nil }
guard !isFinal else { return 0 }

let trimmedText = sourceText.trimmingCharacters(in: .whitespacesAndNewlines)
guard let finalCharacter = trimmedText.last else { return 420 }
if ".!?。!?".contains(finalCharacter) {
return 120
}
if ",;:、,;:—–".contains(finalCharacter) {
return 240
}
return 420
}
}
17 changes: 13 additions & 4 deletions Sources/AirTranslate/Services/OpenAITranslationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ actor OpenAITranslationService {
source: LanguageOption,
target: LanguageOption,
model selectedModel: OpenAIRealtimeTranslationModel,
qualityContext: TranslationQualityContext? = nil,
progress: (@MainActor @Sendable (String) -> Void)? = nil
) async throws -> String {
guard !text.isEmpty else { return text }
Expand All @@ -35,6 +36,7 @@ actor OpenAITranslationService {
source: source,
target: target,
model: selectedModel,
qualityContext: qualityContext,
streaming: true
)
if let streamedText = try await streamTranslation(streamingRequest, progress: progress) {
Expand All @@ -48,6 +50,7 @@ actor OpenAITranslationService {
source: source,
target: target,
model: selectedModel,
qualityContext: qualityContext,
streaming: false
)
let (data, httpResponse) = try await send(request)
Expand All @@ -72,19 +75,25 @@ actor OpenAITranslationService {
source: LanguageOption,
target: LanguageOption,
model: OpenAIRealtimeTranslationModel,
qualityContext: TranslationQualityContext?,
streaming: Bool
) throws -> URLRequest {
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
let baseInstructions = AppText.openAITranslationInstructions(
source: source.localizedTitle,
target: target.localizedTitle
)
let instructions = qualityContext?.enhancing(
instructions: baseInstructions,
target: target
) ?? baseInstructions
request.httpBody = try JSONEncoder().encode(
OpenAIResponseRequest(
model: model.apiModelID,
instructions: AppText.openAITranslationInstructions(
source: source.localizedTitle,
target: target.localizedTitle
),
instructions: instructions,
input: text,
store: false,
stream: streaming ? true : nil
Expand Down
Loading