mirror of
https://github.com/FluidInference/FluidAudio.git
synced 2026-06-11 20:24:36 +00:00
### Why is this change needed? Automatic speech recognition (ASR) systems are trained on massive datasets of general speech, which means they excel at common vocabulary but struggle with domain-specific terminology. In business contexts—earnings calls, medical dictation, legal proceedings—the most critical words are often the ones the model has rarely or never seen: company names like "Saoirse Ronan," product names like "Newrez," or technical jargon unique to an industry. Without intervention, these high-value terms get transcribed as phonetically similar but incorrect common words, turning "Nequi" into "NECI" or "Bose" into "Boz." Keyword boosting (also called context biasing or vocabulary rescoring) addresses this gap by incorporating domain knowledge at inference time. The system is given a list of expected vocabulary terms and uses acoustic evidence—typically CTC log-probabilities—to determine whether the audio actually supports replacing a transcribed word with a vocabulary term. This isn't blind substitution; a well-designed rescorer computes scores for both the original transcription and the candidate vocabulary term, only making replacements when the acoustic evidence favors the domain term. The "context-biasing weight" parameter allows tuning how aggressively to prefer vocabulary terms. The real-world impact is substantial. In our earnings call benchmark, vocabulary rescoring improved F-score from baseline to 92.2%, correctly identifying 1,094 out of 1,271 domain-specific terms. Multi-word alias support. This PR builds on the work from https://github.com/FluidInference/FluidAudio/pull/240 --------- Co-authored-by: Alex-Wengg <hanweng9@gmail.com>
577 lines
22 KiB
Swift
577 lines
22 KiB
Swift
import Foundation
|
|
import RegexBuilder
|
|
|
|
/// HuggingFace-compatible text normalizer for ASR evaluation
|
|
/// Matches the normalization used in the Open ASR Leaderboard
|
|
struct TextNormalizer {
|
|
|
|
private static let additionalDiacritics: [Character: String] = [
|
|
"œ": "oe", "Œ": "OE", "ø": "o", "Ø": "O", "æ": "ae", "Æ": "AE",
|
|
"ß": "ss", "ẞ": "SS", "đ": "d", "Đ": "D", "ð": "d", "Ð": "D",
|
|
"þ": "th", "Þ": "th", "ł": "l", "Ł": "L",
|
|
]
|
|
|
|
private static let britishToAmerican: [String: String] = {
|
|
guard let url = Bundle.module.url(forResource: "english", withExtension: "json"),
|
|
let data = try? Data(contentsOf: url),
|
|
let dictionary = try? JSONSerialization.jsonObject(with: data) as? [String: String]
|
|
else {
|
|
return [:]
|
|
}
|
|
return dictionary
|
|
}()
|
|
|
|
/// Basic text normalizer that matches the reference Python implementation
|
|
static func basicNormalize(_ text: String, removeDiacritics: Bool = false) -> String {
|
|
var normalized = text.lowercased()
|
|
|
|
// Remove words between brackets
|
|
let bracketsPattern = try! NSRegularExpression(pattern: "[<\\[].*?[>\\]]", options: [])
|
|
normalized = bracketsPattern.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: ""
|
|
)
|
|
|
|
// Remove words between parentheses
|
|
let parenthesesPattern = try! NSRegularExpression(pattern: "\\([^)]+?\\)", options: [])
|
|
normalized = parenthesesPattern.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: ""
|
|
)
|
|
|
|
// Apply NFKD normalization and handle diacritics/symbols
|
|
normalized = normalized.precomposedStringWithCompatibilityMapping
|
|
|
|
if removeDiacritics {
|
|
normalized = removeSymbolsAndDiacritics(normalized)
|
|
} else {
|
|
normalized = removeSymbols(normalized)
|
|
}
|
|
|
|
// Replace successive whitespace with single space
|
|
let whitespacePattern = try! NSRegularExpression(pattern: "\\s+", options: [])
|
|
normalized = whitespacePattern.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: " "
|
|
)
|
|
|
|
return normalized.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
private static func removeSymbolsAndDiacritics(_ text: String) -> String {
|
|
return text.compactMap { char in
|
|
if let replacement = additionalDiacritics[char] {
|
|
return replacement
|
|
}
|
|
|
|
let category = char.unicodeScalars.first?.properties.generalCategory
|
|
|
|
// Remove combining marks (diacritics)
|
|
if category == .nonspacingMark {
|
|
return ""
|
|
}
|
|
|
|
// Replace symbols, punctuation, separators with space
|
|
if let cat = category,
|
|
["symbol", "punctuation", "separator"].contains(String(describing: cat).prefix(1).lowercased())
|
|
{
|
|
return " "
|
|
}
|
|
|
|
return String(char)
|
|
}.joined()
|
|
}
|
|
|
|
private static func removeSymbols(_ text: String) -> String {
|
|
return text.compactMap { char in
|
|
let category = char.unicodeScalars.first?.properties.generalCategory
|
|
|
|
// Replace symbols, punctuation, separators with space, but keep diacritics
|
|
if let cat = category,
|
|
["symbol", "punctuation", "separator"].contains(String(describing: cat).prefix(1).lowercased())
|
|
{
|
|
return " "
|
|
}
|
|
|
|
return String(char)
|
|
}.joined()
|
|
}
|
|
|
|
/// Normalize text using HuggingFace ASR leaderboard standards
|
|
/// This matches the normalization used in the official leaderboard evaluation
|
|
static func normalize(_ text: String) -> String {
|
|
var normalized = text
|
|
|
|
normalized = normalized.lowercased()
|
|
|
|
// British to American normalization
|
|
if britishToAmerican.isEmpty {
|
|
print("WARNING: english.json failed to load or is empty!")
|
|
}
|
|
for (british, american) in britishToAmerican {
|
|
let pattern = "\\b" + NSRegularExpression.escapedPattern(for: british) + "\\b"
|
|
normalized = normalized.replacingOccurrences(
|
|
of: pattern,
|
|
with: american,
|
|
options: .regularExpression
|
|
)
|
|
}
|
|
|
|
// Abbreviations (Moved to top)
|
|
let abbreviations = [
|
|
// Titles and names
|
|
"mr": "mister",
|
|
"mrs": "missus",
|
|
"ms": "miss",
|
|
"dr": "doctor",
|
|
"prof": "professor",
|
|
"st": "saint",
|
|
"jr": "junior",
|
|
"sr": "senior",
|
|
"esq": "esquire",
|
|
|
|
// Government and military titles
|
|
"capt": "captain",
|
|
"gov": "governor",
|
|
"ald": "alderman",
|
|
"gen": "general",
|
|
"sen": "senator",
|
|
"rep": "representative",
|
|
"pres": "president",
|
|
"rev": "reverend",
|
|
"hon": "honorable",
|
|
"asst": "assistant",
|
|
"assoc": "associate",
|
|
"lt": "lieutenant",
|
|
"col": "colonel",
|
|
|
|
// Business and other
|
|
"vs": "versus",
|
|
"inc": "incorporated",
|
|
"ltd": "limited",
|
|
"co": "company",
|
|
|
|
// Time and date abbreviations
|
|
"am": "a m",
|
|
"pm": "p m",
|
|
"ad": "ad",
|
|
"bc": "bc",
|
|
]
|
|
|
|
for (abbrev, expansion) in abbreviations {
|
|
let pattern = "\\b" + abbrev + "\\b"
|
|
normalized = normalized.replacingOccurrences(
|
|
of: pattern,
|
|
with: expansion,
|
|
options: .regularExpression
|
|
)
|
|
}
|
|
|
|
let bracketsPattern = try! NSRegularExpression(pattern: "[<\\[].*?[>\\]]", options: [])
|
|
normalized = bracketsPattern.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: ""
|
|
)
|
|
|
|
let parenthesesPattern = try! NSRegularExpression(pattern: "\\([^)]+?\\)", options: [])
|
|
normalized = parenthesesPattern.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: ""
|
|
)
|
|
|
|
// Remove filler words and interjections
|
|
let fillerPattern = try! NSRegularExpression(pattern: "\\b(hmm|mm|mhm|mmm|uh|um)\\b", options: [])
|
|
normalized = fillerPattern.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: ""
|
|
)
|
|
|
|
// Remove stuttering patterns like "th-", "o-", "y-" (single letter followed by dash)
|
|
let stutterPattern = try! NSRegularExpression(pattern: "\\b[a-z]+-\\s*", options: [])
|
|
normalized = stutterPattern.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: ""
|
|
)
|
|
|
|
// Standardize spaces before apostrophes
|
|
normalized = normalized.replacingOccurrences(of: " '", with: "'")
|
|
|
|
// Handle "and a half" → "point five"
|
|
normalized = normalized.replacingOccurrences(of: " and a half", with: " point five")
|
|
|
|
// Add spaces at number/letter boundaries
|
|
let numberLetterPattern1 = try! NSRegularExpression(pattern: "([a-z])([0-9])", options: [])
|
|
normalized = numberLetterPattern1.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: "$1 $2"
|
|
)
|
|
|
|
let numberLetterPattern2 = try! NSRegularExpression(pattern: "([0-9])([a-z])", options: [])
|
|
normalized = numberLetterPattern2.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: "$1 $2"
|
|
)
|
|
|
|
// Remove spaces before suffixes
|
|
let suffixPattern = try! NSRegularExpression(pattern: "([0-9])\\s+(st|nd|rd|th|s)\\b", options: [])
|
|
normalized = suffixPattern.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: "$1$2"
|
|
)
|
|
|
|
normalized = normalized.map { char in
|
|
if let replacement = additionalDiacritics[char] {
|
|
return replacement
|
|
}
|
|
return String(char)
|
|
}.joined()
|
|
|
|
normalized = normalized.replacingOccurrences(of: "$", with: " dollar ")
|
|
normalized = normalized.replacingOccurrences(of: "&", with: " and ")
|
|
normalized = normalized.replacingOccurrences(of: "%", with: " percent ")
|
|
|
|
let punctuationPattern = try! NSRegularExpression(pattern: "[^\\w\\s']", options: [])
|
|
normalized = punctuationPattern.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: " "
|
|
)
|
|
|
|
let contractions = [
|
|
// Basic contractions
|
|
"can't": "can not",
|
|
"won't": "will not",
|
|
"ain't": "aint",
|
|
"let's": "let us",
|
|
"n't": " not",
|
|
"'re": " are",
|
|
"'ve": " have",
|
|
"'ll": " will",
|
|
"'d": " would",
|
|
"'m": " am",
|
|
"'t": " not",
|
|
"'s": " is",
|
|
|
|
// Informal contractions
|
|
"y'all": "you all",
|
|
"wanna": "want to",
|
|
"gonna": "going to",
|
|
"gotta": "got to",
|
|
"i'ma": "i am going to",
|
|
"imma": "i am going to",
|
|
"woulda": "would have",
|
|
"coulda": "could have",
|
|
"shoulda": "should have",
|
|
"ma'am": "madam",
|
|
|
|
// Perfect tenses
|
|
"'d been": " had been",
|
|
"'s been": " has been",
|
|
"'d gone": " had gone",
|
|
"'s gone": " has gone",
|
|
"'d done": " had done",
|
|
"'s got": " has got",
|
|
|
|
// Specific contractions
|
|
"it's": "it is",
|
|
"that's": "that is",
|
|
"there's": "there is",
|
|
"here's": "here is",
|
|
"what's": "what is",
|
|
"where's": "where is",
|
|
"who's": "who is",
|
|
"how's": "how is",
|
|
"i'm": "i am",
|
|
"you're": "you are",
|
|
"we're": "we are",
|
|
"they're": "they are",
|
|
"you've": "you have",
|
|
"we've": "we have",
|
|
"they've": "they have",
|
|
"i've": "i have",
|
|
"you'll": "you will",
|
|
"we'll": "we will",
|
|
"they'll": "they will",
|
|
"i'll": "i will",
|
|
"you'd": "you would",
|
|
"we'd": "we would",
|
|
"they'd": "they would",
|
|
"i'd": "i would",
|
|
"she's": "she is",
|
|
"he's": "he is",
|
|
"she'll": "she will",
|
|
"he'll": "he will",
|
|
"she'd": "she would",
|
|
"he'd": "he would",
|
|
]
|
|
|
|
for (contraction, expansion) in contractions {
|
|
normalized = normalized.replacingOccurrences(of: contraction, with: expansion)
|
|
}
|
|
|
|
// Abbreviations moved to top
|
|
|
|
let numberWords = [
|
|
// English numbers
|
|
"zero": "0", "one": "1", "two": "2", "three": "3", "four": "4",
|
|
"five": "5", "seven": "7", "eight": "8", "nine": "9",
|
|
"ten": "10", "eleven": "11", "twelve": "12", "thirteen": "13",
|
|
"fourteen": "14", "fifteen": "15", "sixteen": "16", "seventeen": "17",
|
|
"eighteen": "18", "nineteen": "19", "twenty": "20", "thirty": "30",
|
|
"forty": "40", "fifty": "50", "sixty": "60", "seventy": "70",
|
|
"eighty": "80", "ninety": "90", "hundred": "100", "thousand": "1000",
|
|
"billion": "1000000000",
|
|
"first": "1st", "second": "2nd", "third": "3rd", "fourth": "4th",
|
|
"fifth": "5th", "sixth": "6th", "seventh": "7th", "eighth": "8th",
|
|
"ninth": "9th", "tenth": "10th", "eleventh": "11th", "twelfth": "12th",
|
|
"thirteenth": "13th", "fourteenth": "14th", "fifteenth": "15th",
|
|
"sixteenth": "16th", "seventeenth": "17th", "eighteenth": "18th",
|
|
"nineteenth": "19th", "twentieth": "20th", "thirtieth": "30th",
|
|
"fortieth": "40th", "fiftieth": "50th", "sixtieth": "60th",
|
|
"seventieth": "70th", "eightieth": "80th", "ninetieth": "90th",
|
|
"hundredth": "100th", "thousandth": "1000th",
|
|
|
|
// Italian numbers
|
|
"uno": "1", "due": "2", "tre": "3", "quattro": "4", "cinque": "5",
|
|
"sei": "6", "sette": "7", "otto": "8", "nove": "9", "dieci": "10",
|
|
"undici": "11", "dodici": "12", "tredici": "13", "quattordici": "14",
|
|
"quindici": "15", "sedici": "16", "diciassette": "17", "diciotto": "18",
|
|
"diciannove": "19", "venti": "20", "trenta": "30", "quaranta": "40",
|
|
"cinquanta": "50", "sessanta": "60", "settanta": "70", "ottanta": "80",
|
|
"novanta": "90", "cento": "100", "mila": "1000", "milione": "1000000",
|
|
"milioni": "1000000", "miliardo": "1000000000", "miliardi": "1000000000",
|
|
|
|
// Italian ordinals
|
|
"primo": "1st", "secondo": "2nd", "terzo": "3rd", "quarto": "4th",
|
|
"quinto": "5th", "sesto": "6th", "settimo": "7th", "ottavo": "8th",
|
|
"nono": "9th", "decimo": "10th", "undicesimo": "11th", "dodicesimo": "12th",
|
|
"tredicesimo": "13th", "quattordicesimo": "14th", "quindicesimo": "15th",
|
|
"ventesimo": "20th", "trentesimo": "30th", "centesimo": "100th",
|
|
|
|
// French numbers
|
|
"zéro": "0", "un": "1", "deux": "2", "trois": "3", "quatre": "4",
|
|
"cinq": "5", "six": "6", "sept": "7", "huit": "8", "neuf": "9",
|
|
"dix": "10", "onze": "11", "douze": "12", "treize": "13", "quatorze": "14",
|
|
"quinze": "15", "seize": "16", "dix-sept": "17", "dix-huit": "18",
|
|
"dix-neuf": "19", "vingt": "20", "trente": "30", "quarante": "40",
|
|
"cinquante": "50", "soixante": "60", "soixante-dix": "70", "quatre-vingts": "80",
|
|
"quatre-vingt-dix": "90", "cent": "100", "mille": "1000", "million": "1000000",
|
|
"millions": "1000000", "milliard": "1000000000", "milliards": "1000000000",
|
|
|
|
// French ordinals
|
|
"premier": "1st", "première": "1st", "deuxième": "2nd", "troisième": "3rd",
|
|
"quatrième": "4th", "cinquième": "5th", "sixième": "6th", "septième": "7th",
|
|
"huitième": "8th", "neuvième": "9th", "dixième": "10th", "onzième": "11th",
|
|
"douzième": "12th", "treizième": "13th", "quatorzième": "14th", "quinzième": "15th",
|
|
"seizième": "16th", "vingtième": "20th", "trentième": "30th", "centième": "100th",
|
|
]
|
|
|
|
// Advanced Number Conversion (e.g. "one hundred" -> "100")
|
|
normalized = convertNumbers(normalized)
|
|
|
|
for (word, digit) in numberWords {
|
|
let pattern = "\\b" + word + "\\b"
|
|
normalized = normalized.replacingOccurrences(
|
|
of: pattern,
|
|
with: digit,
|
|
options: .regularExpression
|
|
)
|
|
}
|
|
|
|
// Remove commas between digits
|
|
let commaPattern = try! NSRegularExpression(pattern: "(\\d),(\\d)", options: [])
|
|
normalized = commaPattern.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: "$1$2"
|
|
)
|
|
|
|
// Merge consecutive digits logic removed due to regression
|
|
|
|
// Remove periods not followed by numbers
|
|
let periodPattern = try! NSRegularExpression(pattern: "\\.([^0-9]|$)", options: [])
|
|
normalized = periodPattern.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: " $1"
|
|
)
|
|
|
|
// Normalize "a d" back to "ad" (handles A.D. -> a d -> ad)
|
|
normalized = normalized.replacingOccurrences(of: "a d", with: "ad")
|
|
|
|
// Handle time formats: "11 35 pm" -> "11 35 p m"
|
|
let timePattern = try! NSRegularExpression(pattern: "\\b(\\d{1,2})\\s+(\\d{2})\\s+(am|pm)\\b", options: [])
|
|
normalized = timePattern.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: "$1 $2 $3"
|
|
)
|
|
|
|
normalized = normalized.replacingOccurrences(of: "€", with: " euro ")
|
|
normalized = normalized.replacingOccurrences(of: "£", with: " pound ")
|
|
normalized = normalized.replacingOccurrences(of: "¥", with: " yen ")
|
|
normalized = normalized.replacingOccurrences(of: "©", with: " copyright ")
|
|
normalized = normalized.replacingOccurrences(of: "®", with: " registered ")
|
|
normalized = normalized.replacingOccurrences(of: "™", with: " trademark ")
|
|
|
|
// Remove symbols not preceded/followed by numbers
|
|
let symbolCleanup1 = try! NSRegularExpression(pattern: "[.$¢€£]([^0-9])", options: [])
|
|
normalized = symbolCleanup1.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: " $1"
|
|
)
|
|
|
|
let symbolCleanup2 = try! NSRegularExpression(pattern: "([^0-9])%", options: [])
|
|
normalized = symbolCleanup2.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: "$1 "
|
|
)
|
|
|
|
let finalCleanPattern = try! NSRegularExpression(pattern: "[^\\w\\s]", options: [])
|
|
normalized = finalCleanPattern.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: " "
|
|
)
|
|
|
|
let whitespacePattern = try! NSRegularExpression(pattern: "\\s+", options: [])
|
|
normalized = whitespacePattern.stringByReplacingMatches(
|
|
in: normalized,
|
|
options: [],
|
|
range: NSRange(location: 0, length: normalized.count),
|
|
withTemplate: " "
|
|
)
|
|
|
|
normalized = normalized.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
return normalized
|
|
}
|
|
|
|
// MARK: - Advanced Number Parsing
|
|
|
|
private static let numberValues: [String: Int] = [
|
|
"zero": 0, "oh": 0,
|
|
"one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
|
|
"six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
|
|
"eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15,
|
|
"sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19,
|
|
"twenty": 20, "thirty": 30, "forty": 40, "fifty": 50,
|
|
"sixty": 60, "seventy": 70, "eighty": 80, "ninety": 90,
|
|
]
|
|
|
|
private static let multipliers: [String: Int] = [
|
|
"hundred": 100,
|
|
"thousand": 1000,
|
|
"million": 1_000_000,
|
|
"billion": 1_000_000_000,
|
|
]
|
|
|
|
private static func convertNumbers(_ text: String) -> String {
|
|
let words = text.components(separatedBy: .whitespaces)
|
|
var result: [String] = []
|
|
var currentNumberWords: [String] = []
|
|
|
|
for word in words {
|
|
if isNumberWord(word) {
|
|
currentNumberWords.append(word)
|
|
} else {
|
|
if !currentNumberWords.isEmpty {
|
|
result.append(parseNumberSequence(currentNumberWords))
|
|
currentNumberWords = []
|
|
}
|
|
result.append(word)
|
|
}
|
|
}
|
|
|
|
if !currentNumberWords.isEmpty {
|
|
result.append(parseNumberSequence(currentNumberWords))
|
|
}
|
|
|
|
return result.joined(separator: " ")
|
|
}
|
|
|
|
private static func isNumberWord(_ word: String) -> Bool {
|
|
return numberValues.keys.contains(word) || multipliers.keys.contains(word)
|
|
}
|
|
|
|
private static func parseNumberSequence(_ words: [String]) -> String {
|
|
var results: [String] = []
|
|
var currentSum = 0
|
|
var lastScale = 0
|
|
|
|
for word in words {
|
|
let val = numberValues[word] ?? multipliers[word] ?? 0
|
|
let isMultiplier = multipliers.keys.contains(word)
|
|
|
|
if isMultiplier {
|
|
if currentSum == 0 {
|
|
currentSum = 1
|
|
}
|
|
currentSum *= val
|
|
lastScale = val
|
|
} else {
|
|
if currentSum == 0 {
|
|
currentSum = val
|
|
lastScale = 1
|
|
} else {
|
|
// Merge conditions:
|
|
// 1. Previous was a multiplier larger than current (e.g. "hundred" ... "five")
|
|
// 2. Previous was a ten (20-90) and current is unit (1-9)
|
|
|
|
var canMerge = false
|
|
if lastScale >= 100 && val < lastScale {
|
|
canMerge = true
|
|
} else if lastScale == 1 && (currentSum % 100 >= 20 && currentSum % 10 == 0) && val < 10 {
|
|
canMerge = true
|
|
}
|
|
|
|
if canMerge {
|
|
currentSum += val
|
|
lastScale = 1
|
|
} else {
|
|
results.append(String(currentSum))
|
|
currentSum = val
|
|
lastScale = 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if currentSum > 0 {
|
|
results.append(String(currentSum))
|
|
}
|
|
|
|
return results.joined(separator: " ")
|
|
}
|
|
|
|
}
|