This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

tools.stt: init real-time speech-to-text using vosk

- Add vosk package (offline speech recognition based on Kaldi)
- Streaming STT that reads WAV audio from stdin
- Outputs text as sentences are recognized
- -w flag shows live words on stderr with corrections
- Supports multiple languages (en, de, fr, es, ru, cn, ja)

+291
+62
5pkgs/vosk/package.nix
··· 1 + { 2 + lib, 3 + python3Packages, 4 + fetchurl, 5 + stdenv, 6 + }: 7 + let 8 + version = "0.3.44"; 9 + 10 + # Platform-specific wheels 11 + wheels = { 12 + "x86_64-linux" = { 13 + url = "https://files.pythonhosted.org/packages/py3/v/vosk/vosk-${version}-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.whl"; 14 + hash = "sha256-JlLvITxMeAfcQK8eeqV43Mz8TcQZKCKia8RalZG8v3Y="; 15 + }; 16 + "aarch64-linux" = { 17 + url = "https://files.pythonhosted.org/packages/py3/v/vosk/vosk-${version}-py3-none-manylinux2014_aarch64.whl"; 18 + hash = "sha256-mZW3+lQ0sTS0vQmJfiSQ28VhFcKtKUPAMGIeEMcDezQ="; 19 + }; 20 + "x86_64-darwin" = { 21 + url = "https://files.pythonhosted.org/packages/py3/v/vosk/vosk-${version}-py3-none-macosx_10_6_universal2.whl"; 22 + hash = "sha256-Ap0LPWpc/4dMV1uKWBSkzM+zdgjP9S6EbAKoxnqIKAE="; 23 + }; 24 + "aarch64-darwin" = { 25 + url = "https://files.pythonhosted.org/packages/py3/v/vosk/vosk-${version}-py3-none-macosx_10_6_universal2.whl"; 26 + hash = "sha256-Ap0LPWpc/4dMV1uKWBSkzM+zdgjP9S6EbAKoxnqIKAE="; 27 + }; 28 + }; 29 + 30 + wheel = 31 + wheels.${stdenv.hostPlatform.system} 32 + or (throw "Unsupported platform: ${stdenv.hostPlatform.system}"); 33 + in 34 + python3Packages.buildPythonPackage { 35 + pname = "vosk"; 36 + inherit version; 37 + format = "wheel"; 38 + 39 + src = fetchurl { 40 + inherit (wheel) url hash; 41 + }; 42 + 43 + dependencies = with python3Packages; [ 44 + cffi 45 + requests 46 + tqdm 47 + srt 48 + websockets 49 + ]; 50 + 51 + # Skip tests as they require audio files and models 52 + doCheck = false; 53 + 54 + pythonImportsCheck = [ "vosk" ]; 55 + 56 + meta = { 57 + description = "Offline speech recognition API based on Kaldi and Vosk"; 58 + homepage = "https://alphacephei.com/vosk/"; 59 + license = lib.licenses.asl20; 60 + platforms = lib.attrNames wheels; 61 + }; 62 + }
+29
tools/stt/flake-module.nix
··· 1 + { ... }: 2 + { 3 + perSystem = 4 + { pkgs, self', ... }: 5 + let 6 + python = pkgs.python3.withPackages (_ps: [ 7 + self'.packages.vosk 8 + ]); 9 + in 10 + { 11 + packages.stt = pkgs.stdenv.mkDerivation { 12 + pname = "stt"; 13 + version = "0.4.0"; 14 + src = ./.; 15 + nativeBuildInputs = [ pkgs.makeWrapper ]; 16 + installPhase = '' 17 + runHook preInstall 18 + mkdir -p $out/bin 19 + cp stt.py $out/bin/stt 20 + chmod +x $out/bin/stt 21 + wrapProgram $out/bin/stt \ 22 + --prefix PATH : ${python}/bin \ 23 + --set PYTHONPATH ${python}/${python.sitePackages} 24 + runHook postInstall 25 + ''; 26 + meta.mainProgram = "stt"; 27 + }; 28 + }; 29 + }
+200
tools/stt/stt.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Real-time streaming speech-to-text using Vosk. 4 + Reads audio from stdin and outputs words as they're recognized. 5 + """ 6 + 7 + import argparse 8 + import json 9 + import os 10 + import struct 11 + import sys 12 + import zipfile 13 + from pathlib import Path 14 + from urllib.request import urlretrieve 15 + 16 + from vosk import KaldiRecognizer, Model, SetLogLevel 17 + 18 + MODEL_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "vosk" 19 + 20 + # Small, fast models for real-time streaming 21 + MODELS = { 22 + "en": "vosk-model-small-en-us-0.15", 23 + "de": "vosk-model-small-de-0.15", 24 + "fr": "vosk-model-small-fr-0.22", 25 + "es": "vosk-model-small-es-0.42", 26 + "ru": "vosk-model-small-ru-0.22", 27 + "cn": "vosk-model-small-cn-0.22", 28 + "ja": "vosk-model-small-ja-0.22", 29 + } 30 + 31 + 32 + def download_model(lang: str) -> Path: 33 + """Download vosk model if not present.""" 34 + model_name = MODELS.get(lang) 35 + if not model_name: 36 + # Fall back to English if language not available 37 + print(f"No model for '{lang}', using English", file=sys.stderr) 38 + model_name = MODELS["en"] 39 + lang = "en" 40 + 41 + model_path = MODEL_DIR / model_name 42 + if model_path.exists(): 43 + return model_path 44 + 45 + print(f"Downloading model {model_name}...", file=sys.stderr) 46 + MODEL_DIR.mkdir(parents=True, exist_ok=True) 47 + 48 + url = f"https://alphacephei.com/vosk/models/{model_name}.zip" 49 + zip_path = MODEL_DIR / f"{model_name}.zip" 50 + 51 + urlretrieve(url, zip_path) 52 + 53 + print("Extracting...", file=sys.stderr) 54 + with zipfile.ZipFile(zip_path, "r") as z: 55 + z.extractall(MODEL_DIR) 56 + zip_path.unlink() 57 + 58 + print("Model ready", file=sys.stderr) 59 + return model_path 60 + 61 + 62 + def parse_wav_header(data: bytes) -> tuple[int, int, int, int]: 63 + """Parse WAV header, return (sample_rate, channels, bits_per_sample, data_offset).""" 64 + if len(data) < 44 or data[:4] != b"RIFF" or data[8:12] != b"WAVE": 65 + raise ValueError("Invalid WAV header") 66 + 67 + pos = 12 68 + sample_rate = channels = bits_per_sample = 0 69 + while pos < len(data) - 8: 70 + chunk_id = data[pos : pos + 4] 71 + chunk_size = struct.unpack("<I", data[pos + 4 : pos + 8])[0] 72 + if chunk_id == b"fmt ": 73 + channels = struct.unpack("<H", data[pos + 10 : pos + 12])[0] 74 + sample_rate = struct.unpack("<I", data[pos + 12 : pos + 16])[0] 75 + bits_per_sample = struct.unpack("<H", data[pos + 22 : pos + 24])[0] 76 + elif chunk_id == b"data": 77 + return sample_rate, channels, bits_per_sample, pos + 8 78 + pos += 8 + chunk_size 79 + 80 + raise ValueError("No data chunk found") 81 + 82 + 83 + def convert_audio(data: bytes, in_rate: int, in_channels: int, in_bits: int) -> bytes: 84 + """Convert audio to 16kHz mono 16-bit for Vosk.""" 85 + import array 86 + 87 + # Align data to sample boundary 88 + bytes_per_sample = in_bits // 8 89 + frame_size = in_channels * bytes_per_sample 90 + aligned_len = (len(data) // frame_size) * frame_size 91 + if aligned_len == 0: 92 + return b"" 93 + data = data[:aligned_len] 94 + 95 + # Convert to samples 96 + if in_bits == 16: 97 + samples = array.array("h", data) 98 + elif in_bits == 8: 99 + samples = array.array("h", [(s - 128) * 256 for s in data]) 100 + else: 101 + raise ValueError(f"Unsupported bit depth: {in_bits}") 102 + 103 + # Convert to mono 104 + if in_channels == 2: 105 + mono = array.array("h") 106 + for i in range(0, len(samples) - 1, 2): 107 + mono.append((samples[i] + samples[i + 1]) // 2) 108 + samples = mono 109 + 110 + # Resample to 16kHz if needed 111 + if in_rate != 16000: 112 + ratio = 16000 / in_rate 113 + new_len = int(len(samples) * ratio) 114 + resampled = array.array("h") 115 + for i in range(new_len): 116 + src_idx = min(int(i / ratio), len(samples) - 1) 117 + resampled.append(samples[src_idx]) 118 + samples = resampled 119 + 120 + return samples.tobytes() 121 + 122 + 123 + def main(): 124 + parser = argparse.ArgumentParser(description="Real-time STT using Vosk") 125 + parser.add_argument( 126 + "-l", 127 + "--language", 128 + default=os.environ.get("VOSK_LANG", "en"), 129 + help="Language: en, de, fr, es, ru, cn, ja (default: en)", 130 + ) 131 + parser.add_argument( 132 + "-w", 133 + "--words", 134 + action="store_true", 135 + help="Output individual words as they're recognized", 136 + ) 137 + args = parser.parse_args() 138 + 139 + # Suppress Vosk logging 140 + SetLogLevel(-1) 141 + 142 + model_path = download_model(args.language) 143 + model = Model(str(model_path)) 144 + 145 + # Read WAV header 146 + stdin = sys.stdin.buffer 147 + header_buf = stdin.read(4096) 148 + sample_rate, channels, bits, data_offset = parse_wav_header(header_buf) 149 + 150 + print(f"Audio: {sample_rate}Hz, {channels}ch, {bits}bit", file=sys.stderr) 151 + print("Listening...", file=sys.stderr) 152 + 153 + # Create recognizer for 16kHz audio 154 + rec = KaldiRecognizer(model, 16000) 155 + rec.SetWords(True) 156 + 157 + # Process initial audio from header buffer 158 + initial_audio = header_buf[data_offset:] 159 + if initial_audio: 160 + converted = convert_audio(initial_audio, sample_rate, channels, bits) 161 + rec.AcceptWaveform(converted) 162 + 163 + # Process audio stream 164 + chunk_size = int(sample_rate * channels * (bits // 8) * 0.1) # 100ms chunks 165 + 166 + while True: 167 + data = stdin.read(chunk_size) 168 + if not data: 169 + break 170 + 171 + converted = convert_audio(data, sample_rate, channels, bits) 172 + 173 + if rec.AcceptWaveform(converted): 174 + result = json.loads(rec.Result()) 175 + text = result.get("text", "") 176 + if text: 177 + if args.words: 178 + # Show final sentence on stderr with period and newline 179 + print(f"\r\033[K{text}.", file=sys.stderr, flush=True) 180 + print(text, flush=True) 181 + elif args.words: 182 + partial = json.loads(rec.PartialResult()) 183 + text = partial.get("partial", "") 184 + if text: 185 + # Show partial with corrections on stderr 186 + print(f"\r\033[K{text}", end="", file=sys.stderr, flush=True) 187 + 188 + # Final result 189 + result = json.loads(rec.FinalResult()) 190 + text = result.get("text", "") 191 + if text: 192 + if args.words: 193 + print(f"\r\033[K{text}.", file=sys.stderr, flush=True) 194 + print(text, flush=True) 195 + 196 + print("Stream ended", file=sys.stderr) 197 + 198 + 199 + if __name__ == "__main__": 200 + main()