swanada 1.0.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.
swanada/__init__.py ADDED
@@ -0,0 +1,102 @@
1
+ """Swanada: In-browser & multi-runtime 16 kHz Multilingual Speech-to-Text."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ from .audio import (
6
+ load_audio,
7
+ resample_audio,
8
+ parse_wav,
9
+ save_wav,
10
+ )
11
+ from .frontend import (
12
+ extract_log_mel_features,
13
+ extract_log_mel_onnx,
14
+ create_mel_filterbank,
15
+ create_padded_hann_window,
16
+ hz_to_mel,
17
+ mel_to_hz,
18
+ )
19
+ from .normalizer import (
20
+ dedup_marks,
21
+ repair_brahmic_orthography,
22
+ normalize_indic_text,
23
+ assemble_text,
24
+ join_subwords,
25
+ )
26
+ from .denoise import (
27
+ DenoiseTelemetry,
28
+ enhance_speech_with_telemetry,
29
+ denoise_pcm_16k,
30
+ )
31
+ from .decoder import (
32
+ decode_ctc,
33
+ decode_ctc_tokens,
34
+ is_special_token,
35
+ CTCDecodeResult,
36
+ CTCGreedyDecoder,
37
+ )
38
+ from .engine import (
39
+ AcousticEngine,
40
+ TranscriptionResult,
41
+ )
42
+ from .core import (
43
+ Swanada,
44
+ )
45
+ from .vocab import (
46
+ REGISTRY,
47
+ Vocabulary,
48
+ LanguageMeta,
49
+ detect_script,
50
+ devanagari_to_iast,
51
+ transliterate_iast,
52
+ SUPPORTED_LANGUAGES,
53
+ BLANK_TOKEN_ID,
54
+ UNK_TOKEN_ID,
55
+ )
56
+ from .streaming import (
57
+ StreamingCTCDecoder,
58
+ StreamingTranscript,
59
+ SwanadaStream,
60
+ )
61
+
62
+ __all__ = [
63
+ "__version__",
64
+ "Swanada",
65
+ "SwanadaStream",
66
+ "StreamingCTCDecoder",
67
+ "StreamingTranscript",
68
+ "TranscriptionResult",
69
+ "AcousticEngine",
70
+ "REGISTRY",
71
+ "Vocabulary",
72
+ "LanguageMeta",
73
+ "detect_script",
74
+ "devanagari_to_iast",
75
+ "transliterate_iast",
76
+ "SUPPORTED_LANGUAGES",
77
+ "BLANK_TOKEN_ID",
78
+ "UNK_TOKEN_ID",
79
+ "load_audio",
80
+ "resample_audio",
81
+ "parse_wav",
82
+ "save_wav",
83
+ "extract_log_mel_features",
84
+ "extract_log_mel_onnx",
85
+ "create_mel_filterbank",
86
+ "create_padded_hann_window",
87
+ "hz_to_mel",
88
+ "mel_to_hz",
89
+ "dedup_marks",
90
+ "repair_brahmic_orthography",
91
+ "normalize_indic_text",
92
+ "assemble_text",
93
+ "join_subwords",
94
+ "DenoiseTelemetry",
95
+ "enhance_speech_with_telemetry",
96
+ "denoise_pcm_16k",
97
+ "decode_ctc",
98
+ "decode_ctc_tokens",
99
+ "is_special_token",
100
+ "CTCDecodeResult",
101
+ "CTCGreedyDecoder",
102
+ ]
swanada/audio.py ADDED
@@ -0,0 +1,89 @@
1
+ """Audio loading, resampling, and WAV serialization for Swanada."""
2
+
3
+ import io
4
+ import math
5
+ from pathlib import Path
6
+ from typing import Tuple, Union
7
+ import numpy as np
8
+
9
+
10
+ def resample_audio(
11
+ wav: np.ndarray,
12
+ src_sr: int,
13
+ target_sr: int = 16000,
14
+ ) -> np.ndarray:
15
+ """Resample audio signal to target sample rate.
16
+
17
+ Uses polyphase FIR resampling (scipy.signal.resample_poly) with GCD reduction,
18
+ falling back to linear interpolation if scipy is unavailable.
19
+ """
20
+ if src_sr == target_sr:
21
+ return wav.astype(np.float32)
22
+
23
+ if len(wav) == 0:
24
+ return np.zeros(0, dtype=np.float32)
25
+
26
+ try:
27
+ import scipy.signal
28
+ g = math.gcd(target_sr, src_sr)
29
+ up = target_sr // g
30
+ down = src_sr // g
31
+ resampled = scipy.signal.resample_poly(wav, up, down)
32
+ return resampled.astype(np.float32)
33
+ except Exception:
34
+ num_samples = max(1, int(round(len(wav) * target_sr / src_sr)))
35
+ t_in = np.arange(len(wav))
36
+ t_out = np.linspace(0, len(wav) - 1, num_samples)
37
+ return np.interp(t_out, t_in, wav).astype(np.float32)
38
+
39
+
40
+ def load_audio(
41
+ source: Union[str, Path, bytes, np.ndarray],
42
+ target_sr: int = 16000,
43
+ ) -> np.ndarray:
44
+ """Load audio from file path, bytes, or ndarray, converting to 16 kHz mono Float32."""
45
+ sr = target_sr
46
+
47
+ if isinstance(source, (str, Path)):
48
+ import soundfile as sf
49
+ wav, sr = sf.read(str(source), dtype="float32")
50
+ elif isinstance(source, (bytes, bytearray)):
51
+ import soundfile as sf
52
+ wav, sr = sf.read(io.BytesIO(source), dtype="float32")
53
+ elif isinstance(source, np.ndarray):
54
+ wav = source.astype(np.float32)
55
+ else:
56
+ raise TypeError(f"Unsupported audio input type: {type(source)}")
57
+
58
+ # Downmix multi-channel to mono
59
+ if wav.ndim > 1:
60
+ wav = np.mean(wav, axis=1 if wav.shape[1] < wav.shape[0] else 0)
61
+
62
+ # Scale integer PCM if unnormalized
63
+ if np.issubdtype(wav.dtype, np.integer):
64
+ wav = wav.astype(np.float32) / 32768.0
65
+
66
+ # Resample if sample rate differs
67
+ if sr != target_sr:
68
+ wav = resample_audio(wav, sr, target_sr)
69
+
70
+ return wav.astype(np.float32)
71
+
72
+
73
+ def parse_wav(data: bytes) -> Tuple[np.ndarray, int]:
74
+ """Parse raw WAV byte stream into mono Float32 array and sample rate."""
75
+ import soundfile as sf
76
+ wav, sr = sf.read(io.BytesIO(data), dtype="float32")
77
+ if wav.ndim > 1:
78
+ wav = np.mean(wav, axis=1)
79
+ return wav.astype(np.float32), sr
80
+
81
+
82
+ def save_wav(
83
+ path: Union[str, Path],
84
+ pcm: np.ndarray,
85
+ sample_rate: int = 16000,
86
+ ) -> None:
87
+ """Save Float32 audio as 16-bit PCM WAV."""
88
+ import soundfile as sf
89
+ sf.write(str(path), pcm.astype(np.float32), sample_rate, subtype="PCM_16")
swanada/cli.py ADDED
@@ -0,0 +1,203 @@
1
+ """Command-line interface for Swanada (Multilingual Speech-to-Text)."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ from pathlib import Path
7
+ import sys
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from .audio import load_audio
11
+ from .core import Swanada
12
+ from .engine import TranscriptionResult
13
+ from .vocab import SUPPORTED_LANGUAGES
14
+
15
+
16
+ def parse_cli_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
17
+ parser = argparse.ArgumentParser(
18
+ prog="swanada",
19
+ description="Swanada (स्वनद) — 16 kHz Multilingual Indic Speech-to-Text CLI",
20
+ formatter_class=argparse.RawDescriptionHelpFormatter,
21
+ epilog=(
22
+ "Examples:\n"
23
+ " swanada audio.wav -l sa\n"
24
+ " swanada speech.wav -l hi --json -o output.json\n"
25
+ " cat stream.wav | swanada -l kn --stream\n"
26
+ ),
27
+ )
28
+ parser.add_argument(
29
+ "input",
30
+ nargs="?",
31
+ default=None,
32
+ help="Input WAV audio file path or '-' for stdin pipe (default: stdin if piped)",
33
+ )
34
+ parser.add_argument(
35
+ "-l",
36
+ "--language",
37
+ "--lang",
38
+ dest="language",
39
+ default="sa",
40
+ help="Target language code: sa | en | hi | kn | mr | bn | ta | te | ml | gu | pa | or (default: sa)",
41
+ )
42
+ parser.add_argument(
43
+ "-m",
44
+ "--model-dir",
45
+ "--model",
46
+ dest="model_dir",
47
+ default=None,
48
+ help="Path to custom model directory containing ONNX weights and vocabularies",
49
+ )
50
+ parser.add_argument(
51
+ "-o",
52
+ "--output",
53
+ "--out",
54
+ dest="output",
55
+ type=Path,
56
+ default=None,
57
+ help="Output file path (default: stdout)",
58
+ )
59
+ parser.add_argument(
60
+ "--json",
61
+ action="store_true",
62
+ dest="json_output",
63
+ help="Emit structured JSON with text, durationMs, inferenceMs, rtf, and tokens",
64
+ )
65
+ parser.add_argument(
66
+ "--stream",
67
+ action="store_true",
68
+ dest="stream_mode",
69
+ help="Simulate real-time chunked streaming transcription (320ms / 5120 sample quanta)",
70
+ )
71
+ parser.add_argument(
72
+ "--threads",
73
+ type=int,
74
+ default=4,
75
+ dest="threads",
76
+ help="Number of inference execution threads (default: 4)",
77
+ )
78
+ parser.add_argument(
79
+ "--iast",
80
+ action="store_true",
81
+ dest="iast_mode",
82
+ help="Transliterate Classical Sanskrit output to Roman IAST with Vedic accents",
83
+ )
84
+ parser.add_argument(
85
+ "-v",
86
+ "--version",
87
+ action="version",
88
+ version="swanada 1.0.0",
89
+ help="Display version information and exit",
90
+ )
91
+ return parser.parse_args(argv)
92
+
93
+
94
+ def result_to_dict(result: TranscriptionResult, mode: str = "batch", text_override: Optional[str] = None) -> Dict[str, Any]:
95
+ transcript_text = text_override if text_override is not None else result.text
96
+ return {
97
+ "text": transcript_text,
98
+ "language": result.language,
99
+ "durationMs": float(result.duration_ms),
100
+ "duration_ms": float(result.duration_ms),
101
+ "inferenceMs": float(result.inference_ms),
102
+ "inference_ms": float(result.inference_ms),
103
+ "rtf": round(float(result.rtf), 4),
104
+ "tokens": list(result.tokens),
105
+ "mode": mode,
106
+ }
107
+
108
+
109
+ def main(argv: Optional[List[str]] = None) -> int:
110
+ args = parse_cli_args(argv)
111
+
112
+ lang = args.language.lower().strip()
113
+ if lang not in SUPPORTED_LANGUAGES:
114
+ sys.stderr.write(
115
+ f"Error: Unsupported language '{lang}'. Supported languages: {', '.join(sorted(SUPPORTED_LANGUAGES))}\n"
116
+ )
117
+ return 1
118
+
119
+ # Resolve audio source
120
+ input_arg = args.input
121
+ raw_audio_bytes: Optional[bytes] = None
122
+ input_file_path: Optional[Path] = None
123
+
124
+ if input_arg == "-" or (input_arg is None and not sys.stdin.isatty()):
125
+ try:
126
+ raw_audio_bytes = sys.stdin.buffer.read()
127
+ if not raw_audio_bytes:
128
+ sys.stderr.write("Error: Empty standard input received.\n")
129
+ return 1
130
+ except Exception as e:
131
+ sys.stderr.write(f"Error reading from stdin: {e}\n")
132
+ return 1
133
+ elif input_arg:
134
+ input_file_path = Path(input_arg)
135
+ if not input_file_path.exists():
136
+ sys.stderr.write(f"Error: Input file not found: {input_file_path}\n")
137
+ return 1
138
+ else:
139
+ sys.stderr.write("Error: Missing input audio. Provide <input.wav> or pipe via stdin.\n")
140
+ sys.stderr.write("Usage: swanada -l <lang> [-m <model_dir>] [-o <output.txt>] [--json] [--stream] <input.wav>\n")
141
+ return 1
142
+
143
+ # Initialize Engine
144
+ try:
145
+ model_dir = Path(args.model_dir) if args.model_dir else None
146
+ stt = Swanada.create(language=lang, model_dir=model_dir, num_threads=args.threads)
147
+ except Exception as e:
148
+ sys.stderr.write(f"Error initializing Swanada engine: {e}\n")
149
+ return 2
150
+
151
+ # Process Audio
152
+ try:
153
+ if raw_audio_bytes is not None:
154
+ pcm = load_audio(raw_audio_bytes, target_sr=16000)
155
+ else:
156
+ assert input_file_path is not None
157
+ pcm = load_audio(input_file_path, target_sr=16000)
158
+
159
+ if args.stream_mode:
160
+ stream_session = stt.stream(chunk_size_samples=5120)
161
+ chunk_size = 5120
162
+ total_samples = len(pcm)
163
+ for offset in range(0, total_samples, chunk_size):
164
+ chunk = pcm[offset : offset + chunk_size]
165
+ stream_session.push(chunk)
166
+ result = stream_session.finish()
167
+ mode = "streaming"
168
+ else:
169
+ result = stt.transcribe(pcm)
170
+ mode = "batch"
171
+
172
+ final_text = result.text
173
+ if args.iast_mode and lang == "sa" and final_text:
174
+ final_text = Swanada.transliterate_iast(final_text)
175
+
176
+ except Exception as e:
177
+ sys.stderr.write(f"Error during transcription: {e}\n")
178
+ return 2
179
+
180
+ # Format Output
181
+ if args.json_output:
182
+ res_dict = result_to_dict(result, mode=mode, text_override=final_text)
183
+ output_payload = json.dumps(res_dict, ensure_ascii=False, indent=2) + "\n"
184
+ else:
185
+ output_payload = final_text + "\n"
186
+
187
+ # Route Output
188
+ if args.output:
189
+ try:
190
+ args.output.parent.mkdir(parents=True, exist_ok=True)
191
+ args.output.write_text(output_payload, encoding="utf-8")
192
+ except Exception as e:
193
+ sys.stderr.write(f"Error writing to output file {args.output}: {e}\n")
194
+ return 1
195
+ else:
196
+ sys.stdout.write(output_payload)
197
+ sys.stdout.flush()
198
+
199
+ return 0
200
+
201
+
202
+ if __name__ == "__main__":
203
+ sys.exit(main())
swanada/core.py ADDED
@@ -0,0 +1,117 @@
1
+ """High-level Swanada Speech-to-Text Interface."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional, Union
5
+ import numpy as np
6
+
7
+ from .audio import load_audio
8
+ from .engine import AcousticEngine, TranscriptionResult
9
+ from .vocab import REGISTRY, transliterate_iast
10
+
11
+
12
+ def _find_model_asset(filename: str, model_dir: Optional[Union[str, Path]] = None, subfolder: str = "") -> Optional[Path]:
13
+ candidates = []
14
+ if model_dir:
15
+ md = Path(model_dir)
16
+ if subfolder:
17
+ candidates.append(md / subfolder / filename)
18
+ candidates.append(md / filename)
19
+ # In case model_dir is e.g. models/onnx, check parent
20
+ if subfolder:
21
+ candidates.append(md.parent / subfolder / filename)
22
+ candidates.append(md.parent / filename)
23
+
24
+ # Standard repository locations
25
+ repo_models = Path(__file__).resolve().parents[3] / "models"
26
+ if subfolder:
27
+ candidates.append(repo_models / subfolder / filename)
28
+ candidates.append(repo_models / filename)
29
+
30
+ for c in candidates:
31
+ if c.exists():
32
+ return c
33
+ return None
34
+
35
+
36
+ class Swanada:
37
+ """Swanada Speech-to-Text Transcriber.
38
+
39
+ Zero server dependencies. Client-side inference via ONNX.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ language: str = "sa",
45
+ model_dir: Optional[Union[str, Path]] = None,
46
+ num_threads: int = 4,
47
+ preprocessor_mode: str = "analytical",
48
+ ):
49
+ self.language = language
50
+
51
+ meta = REGISTRY.get(language)
52
+ model_name = meta.model_filename if meta else f"swanada_{language}_ctc_int8.onnx"
53
+ vocab_name = meta.vocab_filename if meta else f"{language}_vocab.json"
54
+
55
+ model_path = _find_model_asset(model_name, model_dir, "onnx")
56
+ if model_path is None and language == "sa":
57
+ model_path = _find_model_asset("sushrota_sanskrit_ctc_int8.onnx", model_dir, "onnx")
58
+
59
+ vocab_path = _find_model_asset(vocab_name, model_dir, "vocabularies")
60
+ if vocab_path is None and language == "sa":
61
+ vocab_path = _find_model_asset("sanskrit_vocab.json", model_dir, "vocabularies")
62
+
63
+ preprocessor_path = _find_model_asset("preprocessor.onnx", model_dir)
64
+
65
+ self.engine = AcousticEngine(
66
+ model_path=model_path,
67
+ vocab_path=vocab_path,
68
+ preprocessor_path=preprocessor_path,
69
+ language=language,
70
+ num_threads=num_threads,
71
+ preprocessor_mode=preprocessor_mode,
72
+ )
73
+
74
+ @staticmethod
75
+ def transliterate_iast(text: str) -> str:
76
+ """Transliterate Classical Sanskrit Devanagari text to Roman IAST with Vedic pitch accents."""
77
+ return transliterate_iast(text)
78
+
79
+ @classmethod
80
+ def create(
81
+ cls,
82
+ language: str = "sa",
83
+ model_dir: Optional[Union[str, Path]] = None,
84
+ num_threads: int = 4,
85
+ preprocessor_mode: str = "analytical",
86
+ ) -> "Swanada":
87
+ """Factory constructor matching TypeScript API."""
88
+ return cls(
89
+ language=language,
90
+ model_dir=model_dir,
91
+ num_threads=num_threads,
92
+ preprocessor_mode=preprocessor_mode,
93
+ )
94
+
95
+ def transcribe(
96
+ self,
97
+ audio: Union[str, Path, bytes, np.ndarray],
98
+ sample_rate: int = 16000,
99
+ ) -> TranscriptionResult:
100
+ """Transcribe audio input to normalized text."""
101
+ return self.engine.transcribe(audio, sample_rate=sample_rate)
102
+
103
+ def stream(
104
+ self,
105
+ chunk_size_samples: int = 5120,
106
+ left_context_samples: int = 30720,
107
+ max_buffer_samples: int = 61440,
108
+ ) -> "SwanadaStream":
109
+ """Creates a streaming audio transcription pipeline."""
110
+ from .streaming import SwanadaStream
111
+ return SwanadaStream(
112
+ self.engine,
113
+ chunk_size_samples=chunk_size_samples,
114
+ left_context_samples=left_context_samples,
115
+ max_buffer_samples=max_buffer_samples,
116
+ )
117
+