phonomatch 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phonomatch/__init__.py +57 -0
- phonomatch/application/__init__.py +6 -0
- phonomatch/application/analyzer.py +145 -0
- phonomatch/application/phonomatch.py +133 -0
- phonomatch/domain/__init__.py +26 -0
- phonomatch/domain/config.py +56 -0
- phonomatch/domain/ipa.py +25 -0
- phonomatch/domain/matching.py +109 -0
- phonomatch/domain/models.py +77 -0
- phonomatch/exceptions.py +27 -0
- phonomatch/infrastructure/__init__.py +1 -0
- phonomatch/infrastructure/audio.py +73 -0
- phonomatch/infrastructure/phonetics.py +84 -0
- phonomatch/infrastructure/phrase_decoding.py +251 -0
- phonomatch/infrastructure/recognition.py +442 -0
- phonomatch/interfaces/__init__.py +1 -0
- phonomatch/interfaces/cli.py +204 -0
- phonomatch/interfaces/console.py +198 -0
- phonomatch/interfaces/server/__init__.py +60 -0
- phonomatch/interfaces/server/errors.py +9 -0
- phonomatch/interfaces/server/handlers.py +163 -0
- phonomatch/interfaces/server/http_server.py +91 -0
- phonomatch/interfaces/server/lifecycle.py +56 -0
- phonomatch/interfaces/server/payloads.py +15 -0
- phonomatch-0.1.0.dist-info/METADATA +403 -0
- phonomatch-0.1.0.dist-info/RECORD +29 -0
- phonomatch-0.1.0.dist-info/WHEEL +4 -0
- phonomatch-0.1.0.dist-info/entry_points.txt +3 -0
- phonomatch-0.1.0.dist-info/licenses/LICENSE +203 -0
phonomatch/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Record speech, transcribe it to IPA, and match it against known words."""
|
|
2
|
+
|
|
3
|
+
from .application.analyzer import (
|
|
4
|
+
DEFAULT_MODEL_ID,
|
|
5
|
+
DEFAULT_MODEL_REVISION,
|
|
6
|
+
)
|
|
7
|
+
from .application.phonomatch import PhonoMatch, listen_and_match
|
|
8
|
+
from .domain import (
|
|
9
|
+
DEFAULT_WORDS,
|
|
10
|
+
AnalysisResult,
|
|
11
|
+
CandidateScore,
|
|
12
|
+
MatchResult,
|
|
13
|
+
MatchSettings,
|
|
14
|
+
PhraseAnalysisResult,
|
|
15
|
+
WordAnalysisResult,
|
|
16
|
+
decide_match,
|
|
17
|
+
distance_ratio,
|
|
18
|
+
normalize_ipa,
|
|
19
|
+
)
|
|
20
|
+
from .exceptions import (
|
|
21
|
+
AudioRecordingError,
|
|
22
|
+
OptionalDependencyError,
|
|
23
|
+
PhonoMatchError,
|
|
24
|
+
RecognitionError,
|
|
25
|
+
UnsupportedPhoneError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_model(*args: object, **kwargs: object) -> None:
|
|
30
|
+
"""Load the optional speech-recognition model."""
|
|
31
|
+
from .application.analyzer import load_model as _load_model
|
|
32
|
+
|
|
33
|
+
_load_model(*args, **kwargs)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"DEFAULT_MODEL_ID",
|
|
38
|
+
"DEFAULT_MODEL_REVISION",
|
|
39
|
+
"DEFAULT_WORDS",
|
|
40
|
+
"AnalysisResult",
|
|
41
|
+
"AudioRecordingError",
|
|
42
|
+
"CandidateScore",
|
|
43
|
+
"MatchResult",
|
|
44
|
+
"MatchSettings",
|
|
45
|
+
"OptionalDependencyError",
|
|
46
|
+
"PhonoMatch",
|
|
47
|
+
"PhonoMatchError",
|
|
48
|
+
"PhraseAnalysisResult",
|
|
49
|
+
"RecognitionError",
|
|
50
|
+
"UnsupportedPhoneError",
|
|
51
|
+
"WordAnalysisResult",
|
|
52
|
+
"decide_match",
|
|
53
|
+
"distance_ratio",
|
|
54
|
+
"listen_and_match",
|
|
55
|
+
"load_model",
|
|
56
|
+
"normalize_ipa",
|
|
57
|
+
]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""High-level orchestration API for sound analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from ..domain.config import DEFAULT_SETTINGS, MatchSettings
|
|
11
|
+
from ..domain.matching import decide_match
|
|
12
|
+
from ..domain.models import AnalysisResult, PhraseAnalysisResult, WordAnalysisResult
|
|
13
|
+
from ..exceptions import OptionalDependencyError
|
|
14
|
+
from ..infrastructure.phonetics import (
|
|
15
|
+
Vocabulary,
|
|
16
|
+
phonetic_distance,
|
|
17
|
+
phonetic_maximum_distance,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
DEFAULT_MODEL_ID = "onnx-community/wav2vec2-lv-60-espeak-cv-ft-ONNX"
|
|
21
|
+
DEFAULT_MODEL_REVISION = "c69750f5043e5e1f8a71ab95dd3b98338c280c92"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _recognition_module() -> Any:
|
|
25
|
+
"""Load speech recognition only when a recognition API is used."""
|
|
26
|
+
try:
|
|
27
|
+
from ..infrastructure import recognition
|
|
28
|
+
except ModuleNotFoundError as exc:
|
|
29
|
+
if exc.name in {"numpy", "scipy", "onnxruntime", "huggingface_hub"}:
|
|
30
|
+
raise OptionalDependencyError("recognition") from exc
|
|
31
|
+
raise
|
|
32
|
+
return recognition
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def speech_to_ipa(*args: object, **kwargs: object) -> str:
|
|
36
|
+
"""Lazily dispatch to the optional speech-recognition integration."""
|
|
37
|
+
return str(_recognition_module().speech_to_ipa(*args, **kwargs))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def speech_to_phrase(*args: object, **kwargs: object) -> Any:
|
|
41
|
+
"""Lazily dispatch to the optional phrase-recognition integration."""
|
|
42
|
+
return _recognition_module().speech_to_phrase(*args, **kwargs)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_model(*args: object, **kwargs: object) -> None:
|
|
46
|
+
"""Lazily load the optional speech-recognition model."""
|
|
47
|
+
_recognition_module().load_model(*args, **kwargs)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def unload_models() -> None:
|
|
51
|
+
"""Lazily release optional cached speech-recognition models."""
|
|
52
|
+
_recognition_module().unload_models()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Analyzer:
|
|
56
|
+
"""Transcribe recordings and match them against an IPA vocabulary."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
vocabulary: Vocabulary,
|
|
61
|
+
settings: MatchSettings = DEFAULT_SETTINGS,
|
|
62
|
+
*,
|
|
63
|
+
model_id: str = DEFAULT_MODEL_ID,
|
|
64
|
+
model_revision: Optional[str] = DEFAULT_MODEL_REVISION,
|
|
65
|
+
) -> None:
|
|
66
|
+
self._vocabulary = vocabulary
|
|
67
|
+
self._settings = settings
|
|
68
|
+
self._model_id = model_id
|
|
69
|
+
self._model_revision = model_revision
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def words(self) -> Mapping[str, str]:
|
|
73
|
+
"""Return a defensive copy of the analyzer vocabulary."""
|
|
74
|
+
return dict(self._vocabulary.words)
|
|
75
|
+
|
|
76
|
+
def analyze_file(self, wav_path: str | Path) -> AnalysisResult:
|
|
77
|
+
"""Transcribe and match an existing WAV file."""
|
|
78
|
+
started_at = time.perf_counter()
|
|
79
|
+
ipa = speech_to_ipa(
|
|
80
|
+
wav_path,
|
|
81
|
+
self._vocabulary.phones,
|
|
82
|
+
model_id=self._model_id,
|
|
83
|
+
model_revision=self._model_revision,
|
|
84
|
+
)
|
|
85
|
+
result = self.match_ipa(ipa)
|
|
86
|
+
return AnalysisResult(
|
|
87
|
+
recognized_ipa=result.recognized_ipa,
|
|
88
|
+
match=result.match,
|
|
89
|
+
recognition_seconds=time.perf_counter() - started_at,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def analyze_phrase_file(
|
|
93
|
+
self,
|
|
94
|
+
wav_path: str | Path,
|
|
95
|
+
*,
|
|
96
|
+
beam_size: int = 64,
|
|
97
|
+
max_words: int = 12,
|
|
98
|
+
) -> PhraseAnalysisResult:
|
|
99
|
+
"""Decode and independently match vocabulary words in a WAV file."""
|
|
100
|
+
started_at = time.perf_counter()
|
|
101
|
+
transcription = speech_to_phrase(
|
|
102
|
+
wav_path,
|
|
103
|
+
self._vocabulary,
|
|
104
|
+
model_id=self._model_id,
|
|
105
|
+
model_revision=self._model_revision,
|
|
106
|
+
beam_size=beam_size,
|
|
107
|
+
max_words=max_words,
|
|
108
|
+
)
|
|
109
|
+
word_results = tuple(
|
|
110
|
+
WordAnalysisResult(
|
|
111
|
+
recognized_ipa=word.ipa,
|
|
112
|
+
match=self.match_ipa(word.ipa).match,
|
|
113
|
+
start_seconds=word.start_seconds,
|
|
114
|
+
end_seconds=word.end_seconds,
|
|
115
|
+
)
|
|
116
|
+
for word in transcription.words
|
|
117
|
+
)
|
|
118
|
+
return PhraseAnalysisResult(
|
|
119
|
+
words=word_results,
|
|
120
|
+
sequence_confidence=transcription.confidence,
|
|
121
|
+
sequence_accepted=(
|
|
122
|
+
transcription.confidence >= self._settings.min_confidence
|
|
123
|
+
),
|
|
124
|
+
alternative_words=transcription.alternative,
|
|
125
|
+
recognition_seconds=time.perf_counter() - started_at,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def load_model(self) -> None:
|
|
129
|
+
"""Load the speech model now so later recognition avoids startup delay."""
|
|
130
|
+
load_model(self._model_id, self._model_revision)
|
|
131
|
+
|
|
132
|
+
def unload_model(self) -> None:
|
|
133
|
+
"""Release the cached speech model and request Python memory cleanup."""
|
|
134
|
+
unload_models()
|
|
135
|
+
|
|
136
|
+
def match_ipa(self, ipa: str) -> AnalysisResult:
|
|
137
|
+
"""Match an existing IPA transcription without recording audio."""
|
|
138
|
+
match = decide_match(
|
|
139
|
+
ipa,
|
|
140
|
+
self._vocabulary.words,
|
|
141
|
+
phonetic_distance,
|
|
142
|
+
maximum_distance_function=phonetic_maximum_distance,
|
|
143
|
+
settings=self._settings,
|
|
144
|
+
)
|
|
145
|
+
return AnalysisResult(recognized_ipa=ipa, match=match)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""The public façade for recording, recognition, and pronunciation matching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from contextlib import AbstractContextManager
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from ..domain.config import DEFAULT_SETTINGS, MatchSettings
|
|
11
|
+
from ..domain.models import AnalysisResult, PhraseAnalysisResult
|
|
12
|
+
from ..infrastructure.audio import recorded_audio
|
|
13
|
+
from ..infrastructure.phonetics import Vocabulary
|
|
14
|
+
from .analyzer import DEFAULT_MODEL_ID, DEFAULT_MODEL_REVISION, Analyzer
|
|
15
|
+
|
|
16
|
+
Recording = Callable[[float], AbstractContextManager[Path]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PhonoMatch:
|
|
20
|
+
"""Coordinate microphone capture with pronunciation analysis."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
words: Mapping[str, str] | None = None,
|
|
25
|
+
settings: MatchSettings = DEFAULT_SETTINGS,
|
|
26
|
+
*,
|
|
27
|
+
default_words: bool = False,
|
|
28
|
+
model_id: str = DEFAULT_MODEL_ID,
|
|
29
|
+
model_revision: Optional[str] = DEFAULT_MODEL_REVISION,
|
|
30
|
+
record: Recording = recorded_audio,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._analyzer = Analyzer(
|
|
33
|
+
self._create_vocabulary(words, default_words=default_words),
|
|
34
|
+
settings,
|
|
35
|
+
model_id=model_id,
|
|
36
|
+
model_revision=model_revision,
|
|
37
|
+
)
|
|
38
|
+
self._record = record
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _create_vocabulary(
|
|
42
|
+
words: Mapping[str, str] | None, *, default_words: bool
|
|
43
|
+
) -> Vocabulary:
|
|
44
|
+
if words is None:
|
|
45
|
+
if not default_words:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"a word list is required; pass words={'word': 'ipa'} or set "
|
|
48
|
+
"default_words=True"
|
|
49
|
+
)
|
|
50
|
+
from ..domain.config import DEFAULT_WORDS
|
|
51
|
+
|
|
52
|
+
return Vocabulary.from_words(DEFAULT_WORDS)
|
|
53
|
+
if default_words:
|
|
54
|
+
raise ValueError("pass either words or default_words=True, not both")
|
|
55
|
+
return Vocabulary.from_words(words)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def words(self) -> Mapping[str, str]:
|
|
59
|
+
"""Return a defensive copy of the configured vocabulary."""
|
|
60
|
+
return self._analyzer.words
|
|
61
|
+
|
|
62
|
+
def load_model(self) -> None:
|
|
63
|
+
"""Load the speech model now so later recognition avoids startup delay."""
|
|
64
|
+
self._analyzer.load_model()
|
|
65
|
+
|
|
66
|
+
def unload_model(self) -> None:
|
|
67
|
+
"""Release the cached speech model."""
|
|
68
|
+
self._analyzer.unload_model()
|
|
69
|
+
|
|
70
|
+
def match_ipa(self, ipa: str) -> AnalysisResult:
|
|
71
|
+
"""Match an existing IPA transcription."""
|
|
72
|
+
return self._analyzer.match_ipa(ipa)
|
|
73
|
+
|
|
74
|
+
def analyze_file(self, wav_path: str | Path) -> AnalysisResult:
|
|
75
|
+
"""Analyze an existing WAV file."""
|
|
76
|
+
return self._analyzer.analyze_file(wav_path)
|
|
77
|
+
|
|
78
|
+
def analyze_phrase_file(
|
|
79
|
+
self,
|
|
80
|
+
wav_path: str | Path,
|
|
81
|
+
*,
|
|
82
|
+
beam_size: int = 64,
|
|
83
|
+
max_words: int = 12,
|
|
84
|
+
) -> PhraseAnalysisResult:
|
|
85
|
+
"""Analyze an existing WAV file as a vocabulary word sequence."""
|
|
86
|
+
return self._analyzer.analyze_phrase_file(
|
|
87
|
+
wav_path, beam_size=beam_size, max_words=max_words
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def record_and_analyze_word(
|
|
91
|
+
self,
|
|
92
|
+
*,
|
|
93
|
+
seconds: float = 2.0,
|
|
94
|
+
on_recording_complete: Optional[Callable[[], None]] = None,
|
|
95
|
+
) -> AnalysisResult:
|
|
96
|
+
"""Capture one utterance, then analyze its temporary WAV file."""
|
|
97
|
+
self.load_model()
|
|
98
|
+
with self._record(seconds) as wav_path:
|
|
99
|
+
if on_recording_complete is not None:
|
|
100
|
+
on_recording_complete()
|
|
101
|
+
return self.analyze_file(wav_path)
|
|
102
|
+
|
|
103
|
+
def record_and_analyze_phrase(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
seconds: float = 4.0,
|
|
107
|
+
beam_size: int = 64,
|
|
108
|
+
max_words: int = 12,
|
|
109
|
+
on_recording_complete: Optional[Callable[[], None]] = None,
|
|
110
|
+
) -> PhraseAnalysisResult:
|
|
111
|
+
"""Capture an utterance, then decode it as a vocabulary word sequence."""
|
|
112
|
+
self.load_model()
|
|
113
|
+
with self._record(seconds) as wav_path:
|
|
114
|
+
if on_recording_complete is not None:
|
|
115
|
+
on_recording_complete()
|
|
116
|
+
return self.analyze_phrase_file(
|
|
117
|
+
wav_path,
|
|
118
|
+
beam_size=beam_size,
|
|
119
|
+
max_words=max_words,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def listen_and_match(
|
|
124
|
+
words: Mapping[str, str] | None = None,
|
|
125
|
+
*,
|
|
126
|
+
seconds: float = 2.0,
|
|
127
|
+
settings: MatchSettings = DEFAULT_SETTINGS,
|
|
128
|
+
default_words: bool = False,
|
|
129
|
+
) -> AnalysisResult:
|
|
130
|
+
"""Backward-compatible convenience workflow for one live utterance."""
|
|
131
|
+
return PhonoMatch(
|
|
132
|
+
words, settings, default_words=default_words
|
|
133
|
+
).record_and_analyze_word(seconds=seconds)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Pure domain models, configuration, normalization, and matching rules."""
|
|
2
|
+
|
|
3
|
+
from .config import DEFAULT_SETTINGS, DEFAULT_WORDS, MatchSettings
|
|
4
|
+
from .ipa import normalize_ipa
|
|
5
|
+
from .matching import decide_match, distance_ratio
|
|
6
|
+
from .models import (
|
|
7
|
+
AnalysisResult,
|
|
8
|
+
CandidateScore,
|
|
9
|
+
MatchResult,
|
|
10
|
+
PhraseAnalysisResult,
|
|
11
|
+
WordAnalysisResult,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DEFAULT_SETTINGS",
|
|
16
|
+
"DEFAULT_WORDS",
|
|
17
|
+
"AnalysisResult",
|
|
18
|
+
"CandidateScore",
|
|
19
|
+
"MatchResult",
|
|
20
|
+
"MatchSettings",
|
|
21
|
+
"PhraseAnalysisResult",
|
|
22
|
+
"WordAnalysisResult",
|
|
23
|
+
"decide_match",
|
|
24
|
+
"distance_ratio",
|
|
25
|
+
"normalize_ipa",
|
|
26
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Domain defaults and validated matching configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
|
|
10
|
+
DEFAULT_WORDS: Mapping[str, str] = MappingProxyType(
|
|
11
|
+
{
|
|
12
|
+
"naku": "naku",
|
|
13
|
+
"selim": "selim",
|
|
14
|
+
"tova": "tova",
|
|
15
|
+
"grun": "ɡrun",
|
|
16
|
+
"shaki": "ʃaki",
|
|
17
|
+
"flabkiver": "flæbˈkɪvər",
|
|
18
|
+
"dracarys": "draˈkarys",
|
|
19
|
+
"lykiri": "lyˈkiri",
|
|
20
|
+
"dohaeras": "dohaeˈra:s",
|
|
21
|
+
"umbas": "ˈʊmbæs",
|
|
22
|
+
"rybas": "ˈriːbəs",
|
|
23
|
+
"mazis": "mˈæ.ziz",
|
|
24
|
+
"naejot": "ˈnaeɟot",
|
|
25
|
+
"soves": "ˈsuːvɛs",
|
|
26
|
+
"vezos": "ˈˈveːzos",
|
|
27
|
+
"kepus": "ˈkɛpus",
|
|
28
|
+
"sovetes": "soˈvetes",
|
|
29
|
+
"drakaryssy": "drakaˈryssy",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class MatchSettings:
|
|
36
|
+
"""Thresholds used to accept or reject the best phonetic candidate."""
|
|
37
|
+
|
|
38
|
+
temperature: float = 0.035
|
|
39
|
+
max_distance_ratio: float = 0.1
|
|
40
|
+
min_relative_margin: float = 0.20
|
|
41
|
+
min_confidence: float = 0.80
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
if not math.isfinite(self.temperature) or self.temperature <= 0:
|
|
45
|
+
raise ValueError("temperature must be a finite value greater than zero")
|
|
46
|
+
for name in (
|
|
47
|
+
"max_distance_ratio",
|
|
48
|
+
"min_relative_margin",
|
|
49
|
+
"min_confidence",
|
|
50
|
+
):
|
|
51
|
+
value = getattr(self, name)
|
|
52
|
+
if not math.isfinite(value) or not 0 <= value <= 1:
|
|
53
|
+
raise ValueError(f"{name} must be a finite value between zero and one")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
DEFAULT_SETTINGS = MatchSettings()
|
phonomatch/domain/ipa.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Pure IPA text normalization shared by recognition and matching."""
|
|
2
|
+
|
|
3
|
+
import unicodedata
|
|
4
|
+
|
|
5
|
+
# These are notation aliases, not merely similar sounds. Distinct phonemes such
|
|
6
|
+
# as /r/ and /ɾ/ deliberately remain separate.
|
|
7
|
+
_IPA_EQUIVALENTS = str.maketrans(
|
|
8
|
+
{
|
|
9
|
+
"g": "ɡ",
|
|
10
|
+
":": "ː",
|
|
11
|
+
"꞉": "ː",
|
|
12
|
+
"'": "ʼ",
|
|
13
|
+
"‘": "ʼ",
|
|
14
|
+
"’": "ʼ",
|
|
15
|
+
"ʹ": "ʼ",
|
|
16
|
+
"′": "ʼ",
|
|
17
|
+
"͜": "͡",
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def normalize_ipa(ipa: str) -> str:
|
|
23
|
+
"""Remove separators and canonicalize equivalent IPA notation."""
|
|
24
|
+
compact = "".join(ipa.split()).translate(_IPA_EQUIVALENTS)
|
|
25
|
+
return unicodedata.normalize("NFC", compact)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Domain functions for ranking and accepting phonetic candidates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
|
|
8
|
+
from .config import DEFAULT_SETTINGS, MatchSettings
|
|
9
|
+
from .ipa import normalize_ipa
|
|
10
|
+
from .models import CandidateScore, Decision, MatchResult
|
|
11
|
+
|
|
12
|
+
DistanceFunction = Callable[[str, str], float]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def distance_ratio(cost: float, maximum_cost: float) -> float:
|
|
16
|
+
"""Express an edit cost as a fraction of its theoretical maximum."""
|
|
17
|
+
if cost < 0 or not math.isfinite(cost):
|
|
18
|
+
raise ValueError("distance function must return a finite non-negative value")
|
|
19
|
+
if maximum_cost < 0 or not math.isfinite(maximum_cost):
|
|
20
|
+
raise ValueError(
|
|
21
|
+
"maximum distance function must return a finite non-negative value"
|
|
22
|
+
)
|
|
23
|
+
if maximum_cost == 0:
|
|
24
|
+
return 0.0 if cost == 0 else math.inf
|
|
25
|
+
if cost > maximum_cost:
|
|
26
|
+
raise ValueError("distance cost cannot exceed its stated maximum")
|
|
27
|
+
return cost / maximum_cost
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def unit_edit_maximum(ipa_a: str, ipa_b: str) -> float:
|
|
31
|
+
"""Return the delete-all/insert-all bound for unit-cost edit distance."""
|
|
32
|
+
return float(len(ipa_a) + len(ipa_b))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def decide_match(
|
|
36
|
+
query: str,
|
|
37
|
+
candidates: Mapping[str, str],
|
|
38
|
+
distance_function: DistanceFunction,
|
|
39
|
+
*,
|
|
40
|
+
maximum_distance_function: DistanceFunction = unit_edit_maximum,
|
|
41
|
+
settings: MatchSettings = DEFAULT_SETTINGS,
|
|
42
|
+
) -> MatchResult:
|
|
43
|
+
"""Rank IPA candidates and reject distant or ambiguous results."""
|
|
44
|
+
if not candidates:
|
|
45
|
+
raise ValueError("at least one candidate is required")
|
|
46
|
+
|
|
47
|
+
normalized_query = normalize_ipa(query)
|
|
48
|
+
raw_scores: list[tuple[str, str, float, float, float]] = []
|
|
49
|
+
for word, ipa in candidates.items():
|
|
50
|
+
normalized_ipa = normalize_ipa(ipa)
|
|
51
|
+
raw_distance = distance_function(normalized_query, normalized_ipa)
|
|
52
|
+
ratio = distance_ratio(
|
|
53
|
+
raw_distance,
|
|
54
|
+
maximum_distance_function(normalized_query, normalized_ipa),
|
|
55
|
+
)
|
|
56
|
+
raw_scores.append(
|
|
57
|
+
(
|
|
58
|
+
word,
|
|
59
|
+
normalized_ipa,
|
|
60
|
+
raw_distance,
|
|
61
|
+
ratio,
|
|
62
|
+
-ratio / settings.temperature,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Ratios make candidate ranking comparable across different word lengths.
|
|
67
|
+
raw_scores.sort(key=lambda score: score[3])
|
|
68
|
+
highest_log_score = max(score[4] for score in raw_scores)
|
|
69
|
+
denominator = sum(math.exp(score[4] - highest_log_score) for score in raw_scores)
|
|
70
|
+
scores = [
|
|
71
|
+
CandidateScore(
|
|
72
|
+
word=word,
|
|
73
|
+
ipa=ipa,
|
|
74
|
+
raw_distance=raw_distance,
|
|
75
|
+
distance_ratio=ratio,
|
|
76
|
+
confidence=math.exp(log_score - highest_log_score) / denominator,
|
|
77
|
+
)
|
|
78
|
+
for word, ipa, raw_distance, ratio, log_score in raw_scores
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
best = scores[0]
|
|
82
|
+
second = scores[1] if len(scores) > 1 else None
|
|
83
|
+
relative_margin = _relative_margin(best, second, distance_function)
|
|
84
|
+
accepted = (
|
|
85
|
+
best.distance_ratio <= settings.max_distance_ratio
|
|
86
|
+
and relative_margin >= settings.min_relative_margin
|
|
87
|
+
and best.confidence >= settings.min_confidence
|
|
88
|
+
)
|
|
89
|
+
decision: Decision = "likely_match" if accepted else "ambiguous_or_unknown"
|
|
90
|
+
return MatchResult(decision, best, second, relative_margin)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _relative_margin(
|
|
94
|
+
best: CandidateScore,
|
|
95
|
+
second: CandidateScore | None,
|
|
96
|
+
distance_function: DistanceFunction,
|
|
97
|
+
) -> float:
|
|
98
|
+
if second is None:
|
|
99
|
+
return math.inf
|
|
100
|
+
|
|
101
|
+
candidate_distance = distance_function(best.ipa, second.ipa)
|
|
102
|
+
if not math.isfinite(candidate_distance) or candidate_distance < 0:
|
|
103
|
+
raise ValueError("distance function must return a finite non-negative value")
|
|
104
|
+
if candidate_distance == 0:
|
|
105
|
+
# Identical pronunciations cannot be distinguished from audio alone.
|
|
106
|
+
return 0.0
|
|
107
|
+
|
|
108
|
+
margin = (second.raw_distance - best.raw_distance) / candidate_distance
|
|
109
|
+
return max(-1.0, min(1.0, margin))
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Result models returned by the analyzer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal, Optional
|
|
7
|
+
|
|
8
|
+
Decision = Literal["likely_match", "ambiguous_or_unknown"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class CandidateScore:
|
|
13
|
+
"""The score assigned to one vocabulary entry."""
|
|
14
|
+
|
|
15
|
+
word: str
|
|
16
|
+
ipa: str
|
|
17
|
+
raw_distance: float
|
|
18
|
+
distance_ratio: float
|
|
19
|
+
confidence: float
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class MatchResult:
|
|
24
|
+
"""The matching decision and its strongest candidates."""
|
|
25
|
+
|
|
26
|
+
decision: Decision
|
|
27
|
+
best_candidate: CandidateScore
|
|
28
|
+
second_candidate: Optional[CandidateScore]
|
|
29
|
+
relative_margin: float
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def accepted(self) -> bool:
|
|
33
|
+
return self.decision == "likely_match"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class AnalysisResult:
|
|
38
|
+
"""An IPA transcription together with its vocabulary match."""
|
|
39
|
+
|
|
40
|
+
recognized_ipa: str
|
|
41
|
+
match: MatchResult
|
|
42
|
+
recognition_seconds: Optional[float] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class WordAnalysisResult:
|
|
47
|
+
"""An independently matched word within a phrase."""
|
|
48
|
+
|
|
49
|
+
recognized_ipa: str
|
|
50
|
+
match: MatchResult
|
|
51
|
+
start_seconds: float
|
|
52
|
+
end_seconds: float
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class PhraseAnalysisResult:
|
|
57
|
+
"""A sequence of independently matched vocabulary words."""
|
|
58
|
+
|
|
59
|
+
words: tuple[WordAnalysisResult, ...]
|
|
60
|
+
sequence_confidence: float
|
|
61
|
+
sequence_accepted: bool
|
|
62
|
+
alternative_words: Optional[tuple[str, ...]] = None
|
|
63
|
+
recognition_seconds: Optional[float] = None
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def recognized_ipa(self) -> str:
|
|
67
|
+
"""Return the per-word transcriptions separated by spaces."""
|
|
68
|
+
return " ".join(word.recognized_ipa for word in self.words)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def accepted(self) -> bool:
|
|
72
|
+
"""Return whether every independently scored word was accepted."""
|
|
73
|
+
return (
|
|
74
|
+
self.sequence_accepted
|
|
75
|
+
and bool(self.words)
|
|
76
|
+
and all(word.match.accepted for word in self.words)
|
|
77
|
+
)
|
phonomatch/exceptions.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Exceptions raised by phonomatch integrations."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PhonoMatchError(Exception):
|
|
5
|
+
"""Base class for expected phonomatch failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OptionalDependencyError(PhonoMatchError):
|
|
9
|
+
"""Raised when an operation needs an optional installation extra."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, extra: str) -> None:
|
|
12
|
+
super().__init__(
|
|
13
|
+
f"this feature requires the {extra!r} extra; install it with "
|
|
14
|
+
f"python -m pip install 'phonomatch[{extra}]'"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AudioRecordingError(PhonoMatchError):
|
|
19
|
+
"""Raised when microphone capture fails."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RecognitionError(PhonoMatchError):
|
|
23
|
+
"""Raised when Wav2Vec2Phoneme cannot transcribe a recording."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UnsupportedPhoneError(RecognitionError):
|
|
27
|
+
"""Raised when a vocabulary phone is unavailable in Wav2Vec2Phoneme."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Adapters for audio capture, speech recognition, and phonetic tooling."""
|