lib/phonetics.rb in phonetics-2.0.1 vs lib/phonetics.rb in phonetics-3.0.1

- old
+ new

@@ -1,239 +1,4 @@ # frozen_string_literal: true -require 'delegate' - -module Phonetics - extend self - - # This subclass of the stdlib's String allows us to iterate over each phoneme - # in a string without monkeypatching - # - # Usage: - # Phonetics::String.new("wətɛvɝ").each_phoneme.to_a - # => ["w", "ə", "t", "ɛ", "v", "ɝ"] - class String < SimpleDelegator - # Group all phonemes by how many characters they have. Use this to walk - # through a string finding phonemes (looking for longest ones first) - def self.phonemes_by_length - @phonemes_by_length ||= Phonetics.phonemes.each_with_object( - # This relies on the impicit stable key ordering of Hash objects in Ruby - # 2+ to keep the keys in descending order. - 4 => Set.new, 3 => Set.new, 2 => Set.new, 1 => Set.new - ) do |str, acc| - acc[str.chars.size] << str - end - end - - def each_phoneme - idx = 0 - Enumerator.new do |y| - while idx < chars.length - found = false - self.class.phonemes_by_length.each do |size, phonemes| - next unless idx + size <= chars.length - - candidate = chars[idx..idx + size - 1].join - next unless phonemes.include?(candidate) - - y.yield candidate - idx += size - found = true - break - end - idx += 1 unless found - end - end - end - end - - module Vowels - extend self - - FormantFrequencies = { - # https://en.wikipedia.org/wiki/Formant#Phonetics - 'i' => { F1: 240, F2: 2400, rounded: false }, - 'y' => { F1: 235, F2: 2100, rounded: false }, - 'ɪ' => { F1: 300, F2: 2100, rounded: false }, # Guessing From other vowels - 'e' => { F1: 390, F2: 2300, rounded: false }, - 'ø' => { F1: 370, F2: 1900, rounded: true }, - 'ɛ' => { F1: 610, F2: 1900, rounded: false }, - 'œ' => { F1: 585, F2: 1710, rounded: true }, - 'a' => { F1: 850, F2: 1610, rounded: false }, - 'ɶ' => { F1: 820, F2: 1530, rounded: true }, - 'ɑ' => { F1: 750, F2: 940, rounded: false }, - 'ɒ' => { F1: 700, F2: 760, rounded: true }, - - 'ʌ' => { F1: 600, F2: 1170, rounded: false }, - # copying 'ʌ' for other mid-vowel formants - 'ə' => { F1: 600, F2: 1170, rounded: false }, - 'ɝ' => { F1: 600, F2: 1170, rounded: false, rhotic: true }, - - 'ɔ' => { F1: 500, F2: 700, rounded: true }, - 'ɤ' => { F1: 460, F2: 1310, rounded: false }, - 'o' => { F1: 360, F2: 640, rounded: true }, - 'ɯ' => { F1: 300, F2: 1390, rounded: false }, - 'æ' => { F1: 800, F2: 1900, rounded: false }, # Guessing From other vowels - 'u' => { F1: 350, F2: 650, rounded: true }, # Guessing From other vowels - 'ʊ' => { F1: 350, F2: 650, rounded: true }, - # Frequencies from http://videoweb.nie.edu.sg/phonetic/vowels/measurements.html - }.freeze - - def phonemes - @phonemes ||= FormantFrequencies.keys - end - - # Given two vowels, calculate the (pythagorean) distance between them using - # their F1 and F2 frequencies as x/y coordinates. - # The return value is scaled to a value between 0 and 1 - # TODO: account for rhoticity (F3) - def distance(phoneme1, phoneme2) - formants1 = FormantFrequencies.fetch(phoneme1) - formants2 = FormantFrequencies.fetch(phoneme2) - - @minmax_f1 ||= FormantFrequencies.values.minmax { |a, b| a[:F1] <=> b[:F1] }.map { |h| h[:F1] } - @minmax_f2 ||= FormantFrequencies.values.minmax { |a, b| a[:F2] <=> b[:F2] }.map { |h| h[:F2] } - - # Get an x and y value for each input phoneme scaled between 0.0 and 1.0 - # We'll use the scaled f1 as the 'x' and the scaled f2 as the 'y' - scaled_phoneme1_f1 = (formants1[:F1] - @minmax_f1[0]) / @minmax_f1[1].to_f - scaled_phoneme1_f2 = (formants1[:F2] - @minmax_f2[0]) / @minmax_f2[1].to_f - scaled_phoneme2_f1 = (formants2[:F1] - @minmax_f1[0]) / @minmax_f1[1].to_f - scaled_phoneme2_f2 = (formants2[:F2] - @minmax_f2[0]) / @minmax_f2[1].to_f - - f1_distance = (scaled_phoneme1_f1 - scaled_phoneme2_f1).abs - f2_distance = (scaled_phoneme1_f2 - scaled_phoneme2_f2).abs - - # When we have four values we can use the pythagorean theorem on them - # (order doesn't matter) - Math.sqrt((f1_distance**2) + (f2_distance**2)) - end - end - - module Consonants - extend self - - # This chart (columns 2 through the end, anyway) is a direct port of - # https://en.wikipedia.org/wiki/International_Phonetic_Alphabet#Letters - # We store the consonant table in this format to make updating it easier. - # - # rubocop:disable Layout/TrailingWhitespace - ChartData = %( | Labio-velar | Bi-labial | Labio-dental | Linguo-labial | Dental | Alveolar | Post-alveolar | Retro-flex | Palatal | Velar | Uvular | Pharyngeal | Glottal - Nasal | | m̥ m | ɱ | n̼ | | n̥ n | | ɳ̊ ɳ | ɲ̊ ɲ | ŋ̊ ŋ | ɴ | | - Stop | | p b | p̪ b̪ | t̼ d̼ | | t d | | ʈ ɖ | c ɟ | k g | q ɢ | ʡ | ʔ - Sibilant fricative | | | | | | s z | ʃ ʒ | ʂ ʐ | ɕ ʑ | | | | - Non-sibilant fricative | | ɸ β | f v | θ̼ ð̼ | θ ð | θ̠ ð̠ | ɹ̠̊˔ ɹ̠˔ | ɻ˔ | ç ʝ | x ɣ | χ ʁ | ħ ʕ | h ɦ - Approximant | w | | ʋ̥ ʋ | | | ɹ̥ ɹ | | ɻ̊ ɻ | j̊ j | ɰ̊ ɰ | | | ʔ̞ - Tap/flap | | ⱱ̟ | ⱱ | ɾ̼ | | ɾ̥ ɾ | | ɽ̊ ɽ | | | ɢ̆ | ʡ̆ | - Trill | | ʙ̥ ʙ | | | | r̥ r | | | | | ʀ̥ ʀ | ʜ ʢ | - Lateral fricative | | | | | | ɬ ɮ | | ɭ̊˔ ɭ˔ | ʎ̝̊ ʎ̝ | ʟ̝̊ ʟ̝ | | | - Lateral approximant | | | | | | l̥ l | | ɭ̊ ɭ | ʎ̥ ʎ | ʟ̥ ʟ | ʟ̠ | | - Lateral tap/flap | | | | | | ɺ | | ɭ̆ | ʎ̆ | ʟ̆ | | | - ) - # rubocop:enable Layout/TrailingWhitespace - - # Parse the ChartData into a lookup table where we can retrieve attributes - # for each phoneme - def features - @features ||= begin - header, *manners = ChartData.lines - - _, *positions = header.chomp.split(' | ') - positions.map(&:strip!) - - # Remove any trailing blank lines - manners.pop while manners.last.to_s.strip.empty? - - position_indexes = Hash[*positions.each_with_index.to_a.flatten] - - @position_count = positions.size - - manners.each_with_object({}) do |row, phonemes| - manner, *columns = row.chomp.split(' | ') - manner.strip! - positions.zip(columns).each do |position, phoneme_text| - data = { - position: position, - position_index: position_indexes[position], - manner: manner, - } - # If there is a character in the first byte then this articulation - # has a voiceless phoneme. The symbol may use additional characters - # as part of the phoneme symbol. - unless phoneme_text[0] == ' ' - # Take the first non-blank character string - symbol = phoneme_text.chars.take_while { |char| char != ' ' }.join - phoneme_text = phoneme_text[symbol.chars.size..-1] - - phonemes[symbol] = data.merge(voiced: false) - end - # If there's a character anywhere left in the string then this - # articulation has a voiced phoneme - unless phoneme_text.strip.empty? - symbol = phoneme_text.strip - phonemes[symbol] = data.merge(voiced: true) - end - end - end - end - end - - def phonemes - @phonemes ||= features.keys - end - - # Given two consonants, calculate their difference by summing the - # following: - # * 0.1 if they are not voiced the same - # * 0.3 if they are different manners - # * Up to 0.6 if they are the maximum position difference - def distance(phoneme1, phoneme2) - features1 = features[phoneme1] - features2 = features[phoneme2] - - penalty = 0 - penalty += 0.1 if features1[:voiced] != features2[:voiced] - - penalty += 0.3 if features1[:manner] != features2[:manner] - - # Use up to the remaining 0.6 for penalizing differences in manner - penalty += 0.6 * ((features1[:position_index] - features2[:position_index]).abs / @position_count.to_f) - penalty - end - end - - def phonemes - Vowels.phonemes + Consonants.phonemes - end - - Symbols = Consonants.phonemes.reduce({}) { |acc, p| acc.update p => :consonant }.merge( - Vowels.phonemes.reduce({}) { |acc, p| acc.update p => :vowel } - ) - - def distance(phoneme1, phoneme2) - return 0 if phoneme1 == phoneme2 - - distance_map.fetch(phoneme1).fetch(phoneme2) - end - - def distance_map - @distance_map ||= phonemes.permutation(2).each_with_object(Hash.new { |h, k| h[k] = {} }) do |pair, scores| - p1, p2 = *pair - score = _distance(p1, p2) - scores[p1][p2] = score - scores[p2][p1] = score - end - end - - private - - def _distance(phoneme1, phoneme2) - types = [Symbols.fetch(phoneme1), Symbols.fetch(phoneme2)].sort - if types == %i[consonant vowel] - 1.0 - elsif types == %i[vowel vowel] - Vowels.distance(phoneme1, phoneme2) - elsif types == %i[consonant consonant] - Consonants.distance(phoneme1, phoneme2) - end - end -end +require 'phonetics/distances' +require 'phonetics/transcriptions'