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 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,6 @@
1
+ """Application services and workflows for speech recognition and matching."""
2
+
3
+ from .analyzer import Analyzer
4
+ from .phonomatch import PhonoMatch
5
+
6
+ __all__ = ["Analyzer", "PhonoMatch"]
@@ -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()
@@ -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
+ )
@@ -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."""