-
-
Notifications
You must be signed in to change notification settings - Fork 226
Add custom vocabulary prompt for Whisper models #281
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "hex-app": minor | ||
| --- | ||
|
|
||
| Add custom vocabulary prompt for Whisper models: a new "Custom vocabulary" section in the Transforms tab lets you list names/jargon the transcriber gets wrong. The terms are injected as decoder prompt tokens (Whisper's `initial_prompt` equivalent) before transcription, biasing WhisperKit toward the exact spelling and casing you entered. Applies to Whisper models only; Parakeet ignores the setting. | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -13,6 +13,7 @@ struct WordRemappingsView: View { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ScrollView { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| VStack(alignment: .leading, spacing: 24) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| previewSection | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| customVocabularySection | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| wordRulesSection | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| outputFormattingSection | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -64,6 +65,51 @@ struct WordRemappingsView: View { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private var customVocabularySection: some View { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| VStack(alignment: .leading, spacing: 10) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Text("Custom vocabulary") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .font(.headline) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| GroupBox { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| VStack(alignment: .leading, spacing: 10) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| HStack { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Text("Words the transcriber gets wrong (names, jargon). Whisper models are biased toward these spellings before you speak.") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .settingsCaption() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .fixedSize(horizontal: false, vertical: true) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Spacer() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Toggle( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Enabled", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isOn: Binding( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| get: { store.hexSettings.customVocabularyEnabled }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| set: { store.send(.setCustomVocabularyEnabled($0)) } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .toggleStyle(.switch) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .controlSize(.small) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| TextField( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Comma-separated, e.g. Langton, Kit, TCA, WhisperKit", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| text: Binding( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| get: { store.hexSettings.customVocabulary }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| set: { store.send(.setCustomVocabulary($0)) } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| axis: .vertical | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .textFieldStyle(.roundedBorder) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .lineLimit(2...4) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .disabled(!store.hexSettings.customVocabularyEnabled) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ParakeetModel(rawValue: store.hexSettings.selectedModel) != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Text("Custom vocabulary only applies to Whisper models. Your selected model is Parakeet.") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .settingsCaption() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+80
to
+105
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Disable custom vocabulary controls for Parakeet. The notice does not prevent edits when the selected model is Parakeet. Proposed fix .toggleStyle(.switch)
.controlSize(.small)
+ .disabled(ParakeetModel(rawValue: store.hexSettings.selectedModel) != nil)
}
@@
- .disabled(!store.hexSettings.customVocabularyEnabled)
+ .disabled(
+ !store.hexSettings.customVocabularyEnabled ||
+ ParakeetModel(rawValue: store.hexSettings.selectedModel) != nil
+ )📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .padding(.vertical, 4) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private var wordRulesSection: some View { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| VStack(alignment: .leading, spacing: 10) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Text("Word rules") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import Foundation | ||
|
|
||
| /// Builds a decoder prompt from the user's custom vocabulary so Whisper-family | ||
| /// models are biased toward the exact spelling/casing of names, jargon, and | ||
| /// other words the model commonly gets wrong. | ||
| /// | ||
| /// The prompt is passed to WhisperKit via `DecodingOptions.promptTokens`, | ||
| /// which Whisper prepends to the decoder's prefill tokens. The model treats | ||
| /// them as previously transcribed text and is measurably more likely to | ||
| /// reproduce those spellings — this is the on-device equivalent of Whisper's | ||
| /// `initial_prompt` / "hotwords" feature. | ||
| public enum CustomVocabularyPrompt { | ||
| /// Maximum characters of vocabulary text to inject. Whisper's decoder context | ||
| /// is limited (~224 tokens total, shared with prefill tokens), and an | ||
| /// over-long prompt degrades rather than improves accuracy, so the prompt is | ||
| /// trimmed to the most recent terms that fit. | ||
| public static let maxPromptLength = 220 | ||
|
|
||
| /// Parses a free-form vocabulary list into normalized terms. | ||
| /// Accepts comma- or newline-separated entries; trims whitespace, drops empties. | ||
| public static func parseTerms(_ vocabulary: String) -> [String] { | ||
| vocabulary | ||
| .components(separatedBy: CharacterSet(charactersIn: ",\n")) | ||
| .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } | ||
| .filter { !$0.isEmpty } | ||
| } | ||
|
|
||
| /// Builds the prompt text injected ahead of the decoder's prefill tokens. | ||
| /// | ||
| /// Returns `nil` when the vocabulary is empty or disabled, in which case the | ||
| /// caller should leave `DecodingOptions.promptTokens` unset. | ||
| /// | ||
| /// A short declarative lead-in ("Vocabulary: ...") is used instead of raw | ||
| /// comma soup: Whisper is trained on natural prose, so framing the terms as | ||
| /// a labeled list conditions it more reliably and lowers the odds of the | ||
| /// model echoing stray terms into unrelated transcripts. | ||
| public static func makePromptText(vocabulary: String, isEnabled: Bool) -> String? { | ||
| guard isEnabled else { return nil } | ||
| let terms = parseTerms(vocabulary) | ||
| guard !terms.isEmpty else { return nil } | ||
|
|
||
| // Build incrementally from whole terms that fit. Never splits a term, and | ||
| // handles a single over-long term (or over-long final term) by dropping it — | ||
| // the previous comma-trim loop missed both cases when no comma was present. | ||
| let prefix = "Vocabulary: " | ||
| let suffix = "." | ||
| var includedTerms: [String] = [] | ||
| var length = prefix.count + suffix.count | ||
|
|
||
| for term in terms { | ||
| let separatorLength = includedTerms.isEmpty ? 0 : 2 // ", " | ||
| guard length + separatorLength + term.count <= maxPromptLength else { continue } | ||
| includedTerms.append(term) | ||
| length += separatorLength + term.count | ||
| } | ||
|
|
||
| guard !includedTerms.isEmpty else { return nil } | ||
| return prefix + includedTerms.joined(separator: ", ") + suffix | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import XCTest | ||
|
|
||
| @testable import HexCore | ||
|
|
||
| final class CustomVocabularyPromptTests: XCTestCase { | ||
|
|
||
| // MARK: - parseTerms | ||
|
|
||
| func testParseTermsSplitsOnCommasAndNewlines() { | ||
| let terms = CustomVocabularyPrompt.parseTerms("Langton, Kit\nTCA,WhisperKit") | ||
| XCTAssertEqual(terms, ["Langton", "Kit", "TCA", "WhisperKit"]) | ||
| } | ||
|
|
||
| func testParseTermsTrimsWhitespaceAndDropsEmpties() { | ||
| let terms = CustomVocabularyPrompt.parseTerms(" Langton ,, \n , TCA ,\n\n") | ||
| XCTAssertEqual(terms, ["Langton", "TCA"]) | ||
| } | ||
|
|
||
| func testParseTermsEmptyInput() { | ||
| XCTAssertEqual(CustomVocabularyPrompt.parseTerms(""), []) | ||
| XCTAssertEqual(CustomVocabularyPrompt.parseTerms(" , \n ,"), []) | ||
| } | ||
|
|
||
| // MARK: - makePromptText | ||
|
|
||
| func testMakePromptTextDisabledReturnsNil() { | ||
| XCTAssertNil(CustomVocabularyPrompt.makePromptText(vocabulary: "Langton", isEnabled: false)) | ||
| } | ||
|
|
||
| func testMakePromptTextEmptyVocabularyReturnsNil() { | ||
| XCTAssertNil(CustomVocabularyPrompt.makePromptText(vocabulary: "", isEnabled: true)) | ||
| XCTAssertNil(CustomVocabularyPrompt.makePromptText(vocabulary: " , \n", isEnabled: true)) | ||
| } | ||
|
|
||
| func testMakePromptTextFormatsTermsAsLabeledList() { | ||
| let prompt = CustomVocabularyPrompt.makePromptText(vocabulary: "Langton, TCA", isEnabled: true) | ||
| XCTAssertEqual(prompt, "Vocabulary: Langton, TCA.") | ||
| } | ||
|
|
||
| func testMakePromptTextTrimsOverlongPromptToWholeTerms() { | ||
| // ~30 terms of ~16 chars each exceeds maxPromptLength and must be trimmed | ||
| let terms = (1...30).map { "VocabularyTerm\($0)" } | ||
| let prompt = CustomVocabularyPrompt.makePromptText( | ||
| vocabulary: terms.joined(separator: ", "), | ||
| isEnabled: true | ||
| ) | ||
| guard let prompt else { return XCTFail("expected non-nil prompt") } | ||
| XCTAssertLessThanOrEqual(prompt.count, CustomVocabularyPrompt.maxPromptLength) | ||
| XCTAssertTrue(prompt.hasPrefix("Vocabulary: ")) | ||
| XCTAssertTrue(prompt.hasSuffix(".")) | ||
| // Trimming must not cut a term in half: the tail term is removed whole | ||
| XCTAssertFalse(prompt.contains("VocabularyTerm30")) | ||
| } | ||
|
|
||
| func testMakePromptTextSingleOverlongTermReturnsNil() { | ||
| // One term longer than the whole budget: no comma to trim to, and the | ||
| // term must never be split — so there is nothing usable to prompt with | ||
| let hugeTerm = String(repeating: "a", count: CustomVocabularyPrompt.maxPromptLength) | ||
| XCTAssertNil(CustomVocabularyPrompt.makePromptText(vocabulary: hugeTerm, isEnabled: true)) | ||
| } | ||
|
|
||
| func testMakePromptTextOverlongFinalTermIsDropped() { | ||
| // Short terms fit; a trailing over-long term is dropped rather than | ||
| // pushing the prompt over the cap | ||
| let hugeTail = String(repeating: "z", count: CustomVocabularyPrompt.maxPromptLength) | ||
| let prompt = CustomVocabularyPrompt.makePromptText( | ||
| vocabulary: "Langton, TCA, \(hugeTail)", | ||
| isEnabled: true | ||
| ) | ||
| XCTAssertEqual(prompt, "Vocabulary: Langton, TCA.") | ||
| } | ||
|
|
||
| func testMakePromptTextSkipsOverlongMiddleTermKeepsRest() { | ||
| let hugeMiddle = String(repeating: "m", count: CustomVocabularyPrompt.maxPromptLength) | ||
| let prompt = CustomVocabularyPrompt.makePromptText( | ||
| vocabulary: "Langton, \(hugeMiddle), TCA", | ||
| isEnabled: true | ||
| ) | ||
| XCTAssertEqual(prompt, "Vocabulary: Langton, TCA.") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add the GitHub issue or PR reference.
Append the actual issue or PR number in
(#123)format.Proposed fix
As per coding guidelines, include a GitHub issue or PR number in
(#123)format.🤖 Prompt for AI Agents
Source: Coding guidelines