Skip to content
Merged
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
4 changes: 2 additions & 2 deletions Packages/LiveTranscribeKit/Sources/Bench/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ struct BenchOptions {
var verbose = false
/// A speech-to-text model to measure instead of the default, to compare models on the same clips.
var sttModel: String?
/// The Language setting, for a model that is told the language to write (Cohere Transcribe): a
/// code, such as `de`.
/// The Language setting, for a model that can be told the language to write (Qwen3-ASR,
/// Whisper, Cohere Transcribe): a code, such as `de`. Absent is Automatic.
var sttLanguage: String?
/// Export untouched ASR inputs for synthetic cleanup-data preparation, without cleanup.
var asrOutput: URL?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import Dictation
import Permissions
import Shared
import SwiftUI
import Transcription
import TranscriptUI
import Updates

/// The menu bar menu: dictation status and controls, stopping the live transcript, the cleanup
/// level, the microphone, the app's windows, About and, in a release, updates.
/// level, the speech model's language, the microphone, the app's windows, About and, in a
/// release, updates.
///
/// Made for `MenuBarExtra { MenuBarContent(context:) } label: { MenuBarLabel(context:) }` with
/// `.menuBarExtraStyle(.menu)`, so it uses only views a menu can show. What it says and allows
Expand All @@ -17,6 +19,8 @@ public struct MenuBarContent: View {
private static let d = AppSettings.defaults

@AppStorage(AppSettingsKey.cleanupLevel.rawValue) private var cleanupLevel = d.cleanupLevel
@AppStorage(AppSettingsKey.sttModel.rawValue) private var speechModel = d.sttModel
@AppStorage(AppSettingsKey.sttLanguage.rawValue) private var speechLanguage: String?
@AppStorage(AppSettingsKey.showVirtualInputDevices.rawValue)
private var showOtherDevices = d.dictation.showVirtualInputDevices
@AppStorage(AppSettingsKey.dictationHotkey.rawValue) private var dictationHotkey = d.dictation.hotkey
Expand Down Expand Up @@ -76,6 +80,7 @@ public struct MenuBarContent: View {
.tag(level)
}
}
languagePicker
if transcript.supportsInputSelection {
MenuMicrophoneSubmenu(transcript: transcript, showOtherDevices: $showOtherDevices)
}
Expand All @@ -100,6 +105,32 @@ public struct MenuBarContent: View {
.keyboardShortcut("q")
}

/// The Language setting, with the languages the chosen speech model can be told. A model
/// that can't be told one has Automatic alone, and the menu is greyed out.
private var languagePicker: some View {
let model = context.speechModels.catalog.model(forSetting: speechModel)
let menu = SpeechLanguageMenu(model: model)
let selection = Binding(
get: { menu.tag(forSetting: speechLanguage) },
set: { (tag: String) in
let code = SpeechLanguageMenu.setting(forTag: tag)
context.settingsStore.setSpeechLanguage(code)
Log.ui.info("Speech language chosen from the menu bar: \(code ?? "automatic", privacy: .public)")
}
)
return Picker("Language", selection: selection) {
Text(menu.automaticTitle).tag(SpeechLanguageMenu.automatic)
if menu.canChoose {
Divider()
}
ForEach(menu.choices) { choice in
Text(choice.name).tag(choice.code)
}
}
.disabled(!menu.canChoose)
.help(menu.canChoose ? "The language \(model?.name ?? "the speech model") writes" : "This speech model finds the language itself")
}

/// *Check for Updates…*, which a running check disables, or the release a scheduled check
/// found, which can always be brought forward.
private func updatesItem(_ updates: any SoftwareUpdating) -> some View {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Shared
import Transcription

/// The Language setting as a menu offers it for one speech model, in the menu bar and on the
/// model's row in Settings › Models: Automatic first, then the languages the model can be told.
///
/// The setting is one for every model (``AppSettingsKey/sttLanguage``), so a language the model
/// in use doesn't have shows as Automatic, and is kept for a model that has it.
struct SpeechLanguageMenu: Equatable {
/// Automatic's tag in a picker: the setting is absent.
static let automatic = ""

/// What Automatic does: the model finds the language, or, for one that can't, writes the one
/// in brackets, "Automatic (English)".
let automaticTitle: String
/// The languages the model can be told, in the catalog's order; empty for a model that
/// can't be told one, or one outside the catalog, whose menu is Automatic alone.
let choices: [SpeechModelCatalog.LanguageChoice]

init(model: SpeechModelCatalog.Model?) {
choices = model?.languageChoices ?? []
automaticTitle = model?.automaticChoice.map { "Automatic (\($0.name))" } ?? "Automatic"
}

/// Whether there is anything to choose.
var canChoose: Bool { !choices.isEmpty }

/// The picker's tag for the setting: its code if the model has it, and otherwise Automatic's.
func tag(forSetting setting: String?) -> String {
choices.contains { $0.code == setting } ? setting ?? Self.automatic : Self.automatic
}

/// The setting for a tag the picker gives: `nil` for Automatic.
static func setting(forTag tag: String) -> String? {
tag == automatic ? nil : tag
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ struct SpeechModelsSettingsView: View {
status: status(of: model.mac.repository.rawValue, download: library.state(of: model)),
canRemove: canRemove(model),
language: languageChoice(for: model),
languages: SpeechLanguageMenu(model: model),
actions: actions(for: model)
)
}
Expand Down Expand Up @@ -115,15 +116,17 @@ struct SpeechModelsSettingsView: View {
)
}

/// The language a model that is told one writes: the Language setting's, if the model has
/// it, and otherwise its first. `nil` for a model that finds the language itself.
/// The Language setting, for a model that can be told a language: its tag in the model's
/// menu (``SpeechLanguageMenu``). `nil` for a model that can't be told one.
private func languageChoice(for model: SpeechModelCatalog.Model) -> Binding<String>? {
guard let first = model.languageChoices.first else { return nil }
let menu = SpeechLanguageMenu(model: model)
guard menu.canChoose else { return nil }
return Binding(
get: { model.language(forSetting: language)?.code ?? first.code },
set: { code in
get: { menu.tag(forSetting: language) },
set: { tag in
let code = SpeechLanguageMenu.setting(forTag: tag)
context.settingsStore.setSpeechLanguage(code)
Log.ui.info("Speech language chosen in Settings: \(code, privacy: .public)")
Log.ui.info("Speech language chosen in Settings: \(code ?? "automatic", privacy: .public)")
}
)
}
Expand All @@ -145,15 +148,16 @@ struct SpeechModelsSettingsView: View {
}
}

/// One catalog model: what it is for, the language it writes if it's told one, and its status or
/// what can be done with it.
/// One catalog model: what it is for, the language it writes if it can be told one, and its
/// status or what can be done with it.
private struct SpeechModelRow: View {
let model: SpeechModelCatalog.Model
let isDefault: Bool
let status: SpeechModelRowStatus
let canRemove: Bool
/// The Language setting, for a model that is told one.
/// The Language setting, for a model that can be told one.
let language: Binding<String>?
let languages: SpeechLanguageMenu
let actions: SpeechModelStatusView.Actions

var body: some View {
Expand Down Expand Up @@ -183,7 +187,9 @@ private struct SpeechModelRow: View {
.accessibilityElement(children: .combine)
if let language {
Picker("Language", selection: language) {
ForEach(model.languageChoices) { choice in
Text(languages.automaticTitle).tag(SpeechLanguageMenu.automatic)
Divider()
ForEach(languages.choices) { choice in
Text(choice.name).tag(choice.code)
}
}
Expand Down
14 changes: 7 additions & 7 deletions Packages/LiveTranscribeKit/Sources/Shared/AppSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ public enum AppSettingsKey: String, CaseIterable, Sendable {
/// The chosen microphone's Core Audio UID; absent means the system default input.
/// Not part of ``AppSettings``: it is read at every Start rather than once at launch.
case inputDeviceUID
/// The Language setting: the code of the language a speech model that is told one (Cohere
/// Transcribe) writes; absent means the model's first, English. The other models find the
/// language themselves. Not part of ``AppSettings``: it is read at every transcription.
/// The Language setting: the code of the language a speech model that can be told one
/// (Qwen3-ASR, Whisper, Cohere Transcribe) writes; absent means Automatic: the model finds
/// the language, or, for one that can't (Cohere Transcribe), writes English. Not part of
/// ``AppSettings``: it is read at every transcription.
case sttLanguage
}

Expand Down Expand Up @@ -213,17 +214,16 @@ public struct AppSettingsStore: Sendable {
}
}

/// The Language setting's code, or `nil` for each model's first
/// (``AppSettingsKey/sttLanguage``).
/// The Language setting's code, or `nil` for Automatic (``AppSettingsKey/sttLanguage``).
public var speechLanguage: String? {
guard let code = defaults.string(forKey: AppSettingsKey.sttLanguage.rawValue), !code.isEmpty else {
return nil
}
return code
}

/// Stores the language a speech model that is told one writes, by its code; `nil` goes back
/// to each model's first.
/// Stores the language a speech model that can be told one writes, by its code; `nil` goes
/// back to Automatic.
public func setSpeechLanguage(_ code: String?) {
if let code, !code.isEmpty {
defaults.set(code, forKey: AppSettingsKey.sttLanguage.rawValue)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import Foundation

/// Qwen3-ASR's vocabulary as the bytes each token writes, from the model's `vocab.json`, to find
/// the tokens a ``ScriptLock`` lets the model write next.
///
/// The vocabulary holds the byte-level BPE tokens only. Qwen's added tokens (`<|im_end|>`,
/// `<asr_text>`, ...) come after them, and the model's logits after those, padded; none of them
/// writes text, so the lock allows none but the ends of a reply.
final class LockedVocabulary {
enum LoadError: Error, Equatable {
case unreadable(String)
/// A token id is missing, so the ids don't run from 0 without a gap.
case missingToken(Int)
}

/// Each token's bytes, one token after another: token `i`'s run from `starts[i]` up to
/// `starts[i + 1]`.
private let bytes: [UInt8]
private let starts: [Int]
/// The tokens that end the reply, which may follow a whole character only.
private let endOfReply: Set<Int>

/// How many tokens the vocabulary holds.
var count: Int { starts.count - 1 }

/// Reads `vocab.json`, which spells each token's bytes in GPT-2's byte-level alphabet.
init(vocabularyJSON data: Data, endOfReply: Set<Int>) throws(LoadError) {
let ids: [String: Int]
do {
ids = try JSONDecoder().decode([String: Int].self, from: data)
} catch {
throw .unreadable(String(describing: error))
}
var tokens = [String?](repeating: nil, count: ids.count)
for (token, id) in ids where tokens.indices.contains(id) {
tokens[id] = token
}
var bytes: [UInt8] = []
var starts = [0]
for (id, token) in tokens.enumerated() {
guard let token else { throw .missingToken(id) }
// A character outside the alphabet, which no byte-level vocabulary has, writes nothing,
// and the lock never allows writing nothing but the end.
if let written = Self.bytes(spelt: token) {
bytes += written
}
starts.append(bytes.count)
}
self.bytes = bytes
self.starts = starts
self.endOfReply = endOfReply
}

/// The bytes `token` writes; none past the vocabulary.
func bytes(of token: Int) -> ArraySlice<UInt8> {
guard token >= 0, token < count else { return [] }
return bytes[starts[token]..<starts[token + 1]]
}

/// Which of `logitCount` tokens `lock` lets the model write after `partial`: the vocabulary's
/// that write only what the lock allows, and an end of the reply after a whole character.
func allowed(by lock: ScriptLock, after partial: PartialCharacter, logitCount: Int) -> [Bool] {
var allowed = [Bool](repeating: false, count: logitCount)
for token in 0..<min(count, logitCount) where starts[token] < starts[token + 1] {
allowed[token] = lock.partial(after: partial, reading: bytes(of: token)) != nil
}
if partial.isWhole {
for token in endOfReply where token < logitCount {
allowed[token] = true
}
}
return allowed
}

/// The bytes a token spells in GPT-2's byte-level alphabet; `nil` if it has a character the
/// alphabet lacks.
static func bytes(spelt token: String) -> [UInt8]? {
var bytes: [UInt8] = []
bytes.reserveCapacity(token.unicodeScalars.count)
for scalar in token.unicodeScalars {
guard let byte = byteOf[scalar] else { return nil }
bytes.append(byte)
}
return bytes
}

/// GPT-2's byte-level alphabet, as Qwen's vocabulary spells bytes: the printable Latin-1
/// bytes stand for themselves, and the other 68 for the characters from U+0100 on, in byte
/// order.
private static let byteOf: [Unicode.Scalar: UInt8] = {
var byteOf: [Unicode.Scalar: UInt8] = [:]
var next: UInt32 = 0x100
for byte in UInt8.min...UInt8.max {
let printable = (0x21...0x7E).contains(byte) || (0xA1...0xAC).contains(byte) || (0xAE...0xFF).contains(byte)
let scalar: UInt32
if printable {
scalar = UInt32(byte)
} else {
scalar = next
next += 1
}
byteOf[Unicode.Scalar(scalar)!] = byte
}
return byteOf
}()
}
Loading
Loading