voxx 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.
voxx/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """voxx: a local text-to-speech CLI for agents."""
voxx/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from voxx.cli import main
4
+
5
+ sys.exit(main())
voxx/audio.py ADDED
@@ -0,0 +1,102 @@
1
+ """Finishing and writing a Line's audio and a Reference Recording. Worker-side only."""
2
+
3
+ import math
4
+ import warnings
5
+ import wave
6
+
7
+ import numpy as np
8
+ import pyloudnorm
9
+ from scipy.ndimage import minimum_filter1d, uniform_filter1d
10
+ from scipy.signal import resample_poly
11
+
12
+ from voxx.files import replacing
13
+ from voxx.output import USAGE, VoxxError
14
+
15
+ LOUDNESS = -18.0 # LUFS, integrated
16
+ TOLERANCE = 0.5 # LU a Line may fall short of LOUDNESS before it warns
17
+ SILENCE = 0.05 # seconds of silence left before and after the speech
18
+ QUIET = 10 ** (-50 / 20) # a sample this far below the Line's peak counts as silence
19
+ BLOCK = 0.4 # seconds: the loudness meter's gating block
20
+ CEILING = 10 ** (-1 / 20) # the highest peak a Line may reach, a little below full scale so it never clips
21
+ REACH = 0.01 # seconds either side of a peak over which the limiter turns the Line down and back up
22
+ # Every Reference Recording is stored at one rate: as high as any cloning Model hears (Qwen3-TTS Base hears 24 kHz,
23
+ # VoxCPM2 16 kHz), so storing more would keep nothing a Model uses.
24
+ REFERENCE_SAMPLE_RATE = 24_000
25
+
26
+
27
+ def finish(samples, sample_rate):
28
+ """The same loudness and the same short silences for every Line, at the Engine's native sample rate.
29
+ Returns the samples and the Line's warnings."""
30
+ samples = _trim(samples.astype(np.float64), round(sample_rate * SILENCE))
31
+ loudness = _loudness(samples, sample_rate)
32
+ if not np.isfinite(loudness):
33
+ return samples, [{"code": "silent", "message": "The Model read the Line as silence."}]
34
+ samples = _limit(samples * 10 ** ((LOUDNESS - loudness) / 20), round(sample_rate * REACH))
35
+ reached = _loudness(samples, sample_rate)
36
+ if reached >= LOUDNESS - TOLERANCE:
37
+ return samples, []
38
+ return samples, [{"code": "loudness_below_target",
39
+ "message": f"The Line peaks too often to reach {LOUDNESS:g} LUFS without clipping; it is {reached:.1f} LUFS."}]
40
+
41
+
42
+ def prepare_reference(source, path):
43
+ """Normalizes the Reference Recording at `source` into a WAV at `path`, as a Line is finished: mono, at
44
+ REFERENCE_SAMPLE_RATE, at the same loudness and with the same short silences. Returns its warnings."""
45
+ # mlx-audio's reader decodes WAV, MP3 and FLAC itself, and M4A, Ogg, AIFF and more through ffmpeg if installed.
46
+ from mlx_audio.audio_io import read
47
+
48
+ try:
49
+ samples, sample_rate = read(str(source), always_2d=True, dtype="float64")
50
+ except Exception as e:
51
+ raise VoxxError("unreadable_reference", f"Cannot decode {source}: {type(e).__name__}: {e}",
52
+ "Pass a WAV, MP3 or FLAC file to --ref, or install ffmpeg for other formats.",
53
+ {"ref": str(source)}, USAGE)
54
+ samples = samples.mean(axis=1)
55
+ if not len(samples):
56
+ raise VoxxError("silent_reference", f"{source} has no audio.", "Pass a recording of the voice to --ref.",
57
+ {"ref": str(source)}, USAGE)
58
+ gcd = math.gcd(REFERENCE_SAMPLE_RATE, sample_rate)
59
+ samples, found = finish(resample_poly(samples, REFERENCE_SAMPLE_RATE // gcd, sample_rate // gcd), REFERENCE_SAMPLE_RATE)
60
+ if any(w["code"] == "silent" for w in found):
61
+ raise VoxxError("silent_reference", f"{source} is silent.", "Pass a recording of the voice to --ref.",
62
+ {"ref": str(source)}, USAGE)
63
+ write_wav(path, samples, REFERENCE_SAMPLE_RATE)
64
+ return found
65
+
66
+
67
+ def _loudness(samples, sample_rate):
68
+ """Integrated loudness in LUFS; -inf for a Line too quiet to measure."""
69
+ # A Line shorter than one block is measured as one block; a sample short of its length, so rounding never makes it longer.
70
+ meter = pyloudnorm.Meter(sample_rate, block_size=min(BLOCK, (len(samples) - 1) / sample_rate))
71
+ with warnings.catch_warnings(), np.errstate(divide="ignore"):
72
+ warnings.simplefilter("ignore", RuntimeWarning)
73
+ return meter.integrated_loudness(samples)
74
+
75
+
76
+ def _limit(samples, reach):
77
+ """Turns down only the moments that would peak above CEILING, ramping in and out over `reach` samples."""
78
+ needed = CEILING / np.maximum(np.abs(samples), CEILING)
79
+ width = 2 * reach + 1
80
+ # Each over-ceiling sample holds its gain across every window it falls in, so averaging those windows never lets it
81
+ # through; the final minimum only absorbs rounding.
82
+ gain = uniform_filter1d(minimum_filter1d(needed, width, mode="nearest"), width, mode="nearest")
83
+ return samples * np.minimum(gain, needed)
84
+
85
+
86
+ def _trim(samples, margin):
87
+ """Keeps `margin` samples around the speech: the Engine's own where it left that many, silence where it did not."""
88
+ level = np.abs(samples)
89
+ loud = np.flatnonzero(level >= level.max() * QUIET)
90
+ start, end = loud[0], loud[-1] + 1
91
+ kept = samples[max(0, start - margin):end + margin]
92
+ return np.pad(kept, (margin - min(start, margin), margin - min(len(samples) - end, margin)))
93
+
94
+
95
+ def write_wav(path, samples, sample_rate):
96
+ """Mono 16-bit PCM at the Engine's native sample rate."""
97
+ pcm = (np.clip(samples, -1.0, 1.0) * 32767).astype("<i2")
98
+ with replacing(path) as partial, wave.open(str(partial), "wb") as f:
99
+ f.setnchannels(1)
100
+ f.setsampwidth(2)
101
+ f.setframerate(sample_rate)
102
+ f.writeframes(pcm.tobytes())
voxx/cli.py ADDED
@@ -0,0 +1,89 @@
1
+ """The `voxx` command. Never imports an inference library: synthesis runs in the worker (ADR 0002)."""
2
+
3
+ import argparse
4
+ import io
5
+ import sys
6
+ import traceback
7
+ from typing import cast
8
+
9
+ from voxx.models import CAPABILITIES, list_models
10
+ from voxx.output import BUG_HINT, INTERNAL, OK, USAGE, VoxxError, print_error, print_result
11
+
12
+ ARGS_HINT = "Run `voxx schema` to see every command's arguments."
13
+
14
+
15
+ class _Parser(argparse.ArgumentParser):
16
+ def error(self, message):
17
+ raise VoxxError("invalid_arguments", message, ARGS_HINT, {}, USAGE)
18
+
19
+
20
+ def _parser():
21
+ # No --help: stdout carries only JSON, and `voxx schema` describes every command.
22
+ parser = _Parser(prog="voxx", add_help=False)
23
+ commands = parser.add_subparsers(dest="command", required=True, parser_class=_Parser)
24
+ narrate = commands.add_parser("narrate", add_help=False)
25
+ narrate.add_argument("narration")
26
+ narrate.add_argument("--out", required=True)
27
+ narrate.add_argument("--model")
28
+ narrate.add_argument("--line", dest="lines", action="append", default=[])
29
+ narrate.add_argument("--voices-dir")
30
+ models = commands.add_parser("models", add_help=False)
31
+ models.add_argument("--capability", choices=CAPABILITIES)
32
+ installed = models.add_mutually_exclusive_group()
33
+ installed.add_argument("--installed", dest="installed", action="store_const", const=True)
34
+ installed.add_argument("--not-installed", dest="installed", action="store_const", const=False)
35
+ commands.add_parser("voices", add_help=False).add_argument("--voices-dir")
36
+ voice = commands.add_parser("voice", add_help=False).add_subparsers(dest="voice_command", required=True,
37
+ parser_class=_Parser)
38
+ add = voice.add_parser("add", add_help=False)
39
+ add.add_argument("name")
40
+ add.add_argument("--ref", required=True)
41
+ add.add_argument("--ref-text")
42
+ add.add_argument("--model")
43
+ add.add_argument("--voices-dir")
44
+ add.add_argument("--overwrite", action="store_true")
45
+ install = commands.add_parser("install", add_help=False)
46
+ install.add_argument("model")
47
+ commands.add_parser("doctor", add_help=False)
48
+ schema = commands.add_parser("schema", add_help=False)
49
+ # Words, so `voxx schema voice add` names a command of two.
50
+ schema.add_argument("name", nargs="*")
51
+ return parser
52
+
53
+
54
+ def main(argv=None):
55
+ for stream in (sys.stdout, sys.stderr):
56
+ cast(io.TextIOWrapper, stream).reconfigure(encoding="utf-8")
57
+ try:
58
+ args = _parser().parse_args(argv)
59
+ if args.command == "narrate":
60
+ from voxx.narrate import narrate
61
+ data = narrate(args.narration, args.out, args.model, args.lines, args.voices_dir)
62
+ elif args.command == "install":
63
+ from voxx.install import install
64
+ data = install(args.model)
65
+ elif args.command == "doctor":
66
+ from voxx.doctor import doctor
67
+ data = doctor()
68
+ elif args.command == "models":
69
+ data = list_models(args.capability, args.installed)
70
+ elif args.command == "voices":
71
+ from voxx.voices import list_voices
72
+ data = list_voices(args.voices_dir)
73
+ elif args.command == "voice":
74
+ from voxx.voices import add_voice
75
+ data = add_voice(args.name, args.ref, args.ref_text, args.model, args.voices_dir, args.overwrite)
76
+ else:
77
+ from voxx.schema import schema
78
+ data = schema(" ".join(args.name) or None)
79
+ print_result(data)
80
+ except VoxxError as e:
81
+ print_error(e)
82
+ return e.exit_code
83
+ except KeyboardInterrupt:
84
+ print_error(VoxxError("interrupted", "voxx was interrupted.", "Run the command again.", {}, INTERNAL))
85
+ return INTERNAL
86
+ except Exception as e:
87
+ print_error(VoxxError("internal_error", f"{type(e).__name__}: {e}", BUG_HINT, {"traceback": traceback.format_exc()}, INTERNAL))
88
+ return INTERNAL
89
+ return OK
voxx/doctor.py ADDED
@@ -0,0 +1,49 @@
1
+ """`voxx doctor`: what an agent checks before narrating. Reports, never fails, and caches nothing."""
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import subprocess
7
+
8
+ from voxx.files import nearest_existing
9
+ from voxx.models import is_installed, models, voxx_home
10
+
11
+ # Test-only: "<system> <machine>[ rosetta]" stands in for the real platform.
12
+ PLATFORM_ENV = "VOXX_TEST_PLATFORM"
13
+
14
+
15
+ def doctor():
16
+ home = voxx_home()
17
+ disk = nearest_existing(home)
18
+ installed = {m: is_installed(m) for m in models()}
19
+ return {
20
+ "platform": _platform(),
21
+ "home": str(home),
22
+ "disk": {"path": str(disk), "free_bytes": shutil.disk_usage(disk).free},
23
+ "models": {"installed": [m.id for m, yes in installed.items() if yes],
24
+ "not_installed": [m.install_summary() for m, yes in installed.items() if not yes]},
25
+ }
26
+
27
+
28
+ def _platform():
29
+ if PLATFORM_ENV in os.environ:
30
+ system, machine, *rest = os.environ[PLATFORM_ENV].split()
31
+ translated = rest == ["rosetta"]
32
+ else:
33
+ system, machine = platform.system(), platform.machine()
34
+ translated = system == "Darwin" and _sysctl("sysctl.proc_translated") == "1"
35
+ reported = {"system": system, "machine": machine}
36
+ if system == "Darwin" and machine == "arm64":
37
+ return {**reported, "apple_silicon": True, "supported": True}
38
+ if translated:
39
+ return {**reported, "apple_silicon": True, "supported": False,
40
+ "reason": "This Mac has Apple Silicon, but Python runs as x86_64 under Rosetta. Use an arm64 Python."}
41
+ return {**reported, "apple_silicon": False, "supported": False,
42
+ "reason": "voxx v0.1 runs only on Apple Silicon Macs (M1 or later)."}
43
+
44
+
45
+ def _sysctl(name):
46
+ try:
47
+ return subprocess.run(["sysctl", "-n", name], capture_output=True, text=True, timeout=5).stdout.strip()
48
+ except (OSError, subprocess.SubprocessError):
49
+ return ""
@@ -0,0 +1,47 @@
1
+ """Engine adapters. Worker-side only: an Engine may import heavy inference libraries."""
2
+
3
+ from pathlib import Path
4
+ from typing import Protocol
5
+
6
+ import numpy as np
7
+
8
+ from voxx.models import fake_engine_enabled
9
+ from voxx.output import BUG_HINT, VoxxError
10
+
11
+
12
+ class Engine(Protocol):
13
+ def load(self, path: Path, capability: str) -> None:
14
+ """Load the Model installed at path, to use it for capability. Called once per Model per worker; never
15
+ downloads (ADR 0003)."""
16
+ ...
17
+
18
+ def synthesize(self, text: str, *, model: str, voice: str, style: str | None, language: str, seed: int) -> tuple[np.ndarray, int]:
19
+ """Read one whole Line in one pass, the same way every time for the same seed. language is a BCP 47 tag.
20
+ Returns mono float32 samples in [-1, 1] and the native sample rate."""
21
+ ...
22
+
23
+ def clone(self, text: str, *, model: str, reference: Path, reference_text: str, language: str,
24
+ seed: int) -> tuple[np.ndarray, int]:
25
+ """Read one whole Line in one pass in the voice of the Reference Recording, a WAV at reference whose words are
26
+ reference_text; otherwise as synthesize does."""
27
+ ...
28
+
29
+ def transcribe(self, path: Path, *, model: str, language: str | None) -> str:
30
+ """The words spoken in the WAV at path, in language, a BCP 47 tag; None means whatever language is heard."""
31
+ ...
32
+
33
+ def align(self, path: Path, *, model: str, text: str, language: str) -> list[tuple[str, float, float]]:
34
+ """Each word of text, in order and without punctuation, with the seconds into the WAV at path where it starts
35
+ and ends. Each Chinese character counts as a word. language is a BCP 47 tag. Raises if it cannot align text
36
+ with the WAV."""
37
+ ...
38
+
39
+
40
+ def load_engine(name) -> Engine:
41
+ if name == "fake" and fake_engine_enabled():
42
+ from voxx.engines.fake import FakeEngine
43
+ return FakeEngine()
44
+ if name == "mlx-audio":
45
+ from voxx.engines.mlx_audio import MlxAudioEngine
46
+ return MlxAudioEngine()
47
+ raise VoxxError("unknown_engine", f"No Engine named {name!r}.", BUG_HINT, {"engine": name})
voxx/engines/fake.py ADDED
@@ -0,0 +1,122 @@
1
+ """Test-only Engine: deterministic tones and no model files. Registered only when VOXX_TEST_FAKE_ENGINE=1."""
2
+
3
+ import json
4
+ import os
5
+ import signal
6
+ import unicodedata
7
+ import wave
8
+ import zlib
9
+ from collections import Counter
10
+ from typing import NamedTuple
11
+
12
+ import numpy as np
13
+
14
+ from voxx.models import find_model
15
+
16
+ SAMPLE_RATE = 24_000
17
+ SECONDS_PER_CHAR = 0.06
18
+ # "load", "synthesize", "transcribe" or "align": the worker is killed at that step, as the OS does when memory runs out.
19
+ # "synthesize:2" kills it the second time it reaches that step, such as at the second Line it reads.
20
+ CRASH_ENV = "VOXX_TEST_FAKE_CRASH"
21
+ # A JSON object from a Line's text to what the fake Engine transcribes instead; any other Line is transcribed as written.
22
+ HEARD_ENV = "VOXX_TEST_FAKE_HEARD"
23
+ # What the fake Engine hears in a recording it did not read itself, such as a Reference Recording; HEARD_ENV can map it
24
+ # to something else too.
25
+ REFERENCE_HEARD = "这是我自己的声音。"
26
+ # A JSON object from a Line's text to the [word, start, end] list the fake Engine aligns it to instead, or to the
27
+ # error it fails with; any other Line's words are spread evenly over the sound in its WAV.
28
+ ALIGNED_ENV = "VOXX_TEST_FAKE_ALIGNED"
29
+
30
+
31
+ class Shape(NamedTuple):
32
+ amplitude: float
33
+ lead: float # seconds of silence before the tone
34
+ tail: float # seconds of silence after it
35
+ spike: float = 0 # height of one-sample spikes spread evenly through the tone
36
+ spikes: int = 1
37
+
38
+
39
+ # Seeds 0-6 read in these shapes, so tests can hand the postprocessing different audio; every other seed reads in the first.
40
+ SHAPES = [Shape(0.2, 0.25, 0.35), Shape(0.01, 0.0, 0.8), Shape(0.7, 0.6, 0.0), Shape(0.05, 1.2, 0.02),
41
+ Shape(0.01, 0.1, 0.1, spike=0.1), Shape(1e-5, 0.1, 0.1), Shape(0.01, 0.1, 0.1, spike=0.9, spikes=60)]
42
+
43
+
44
+ class FakeEngine:
45
+ # A tone carries no words, so the fake Engine transcribes the text it last read in this worker.
46
+ last_read = None
47
+
48
+ def load(self, path, capability):
49
+ _crash_if("load")
50
+
51
+ def synthesize(self, text, *, model, voice, style, language, seed):
52
+ return _tone(text, seed, SAMPLE_RATE, voice.encode(), (style or "").encode())
53
+
54
+ def clone(self, text, *, model, reference, reference_text, language, seed):
55
+ # The pitch follows the Reference Recording and its words, and the sample rate the Model's registry entry.
56
+ return _tone(text, seed, find_model(model).sample_rate or SAMPLE_RATE, reference.read_bytes(), reference_text.encode())
57
+
58
+ def transcribe(self, path, *, model, language):
59
+ _crash_if("transcribe")
60
+ with wave.open(str(path)) as audio:
61
+ if not any(audio.readframes(audio.getnframes())):
62
+ return ""
63
+ heard = REFERENCE_HEARD if FakeEngine.last_read is None else FakeEngine.last_read
64
+ return json.loads(os.environ.get(HEARD_ENV, "{}")).get(heard, heard)
65
+
66
+ def align(self, path, *, model, text, language):
67
+ _crash_if("align")
68
+ aligned = json.loads(os.environ.get(ALIGNED_ENV, "{}")).get(text)
69
+ if isinstance(aligned, str):
70
+ raise RuntimeError(aligned)
71
+ if aligned is not None:
72
+ return [tuple(word) for word in aligned]
73
+ with wave.open(str(path)) as audio:
74
+ rate = audio.getframerate()
75
+ samples = np.frombuffer(audio.readframes(audio.getnframes()), "<i2")
76
+ sound = np.flatnonzero(samples)
77
+ start, end = (sound[0], sound[-1] + 1) if len(sound) else (0, len(samples))
78
+ words = _words(text)
79
+ step = (end - start) / rate / max(len(words), 1)
80
+ return [(word, start / rate + i * step, start / rate + (i + 1) * step) for i, word in enumerate(words)]
81
+
82
+
83
+ def _tone(text, seed, sample_rate, *voice):
84
+ """A Line read as a tone whose pitch follows the seed and every part of `voice`, so tests can tell what reached
85
+ the Engine."""
86
+ _crash_if("synthesize")
87
+ FakeEngine.last_read = text
88
+ frequency = 200 + np.random.default_rng([seed, *map(zlib.crc32, voice)]).integers(0, 2000)
89
+ shape = SHAPES[seed] if seed < len(SHAPES) else SHAPES[0]
90
+ t = np.arange(int(sample_rate * len(text) * SECONDS_PER_CHAR)) / sample_rate
91
+ tone = shape.amplitude * np.sin(2 * np.pi * frequency * t)
92
+ if shape.spike:
93
+ tone[(2 * np.arange(shape.spikes) + 1) * len(tone) // (2 * shape.spikes)] = shape.spike
94
+ lead, tail = np.zeros(int(sample_rate * shape.lead)), np.zeros(int(sample_rate * shape.tail))
95
+ return np.concatenate([lead, tone, tail]).astype(np.float32), sample_rate
96
+
97
+
98
+ def _words(text):
99
+ """As the real aligner splits a Chinese Line: each Chinese character, and each run of other letters, digits and
100
+ apostrophes."""
101
+ words, word = [], ""
102
+ for c in text:
103
+ if unicodedata.name(c, "").startswith("CJK UNIFIED"):
104
+ words += [word, c] if word else [c]
105
+ word = ""
106
+ elif c == "'" or unicodedata.category(c)[0] in "LN":
107
+ word += c
108
+ elif word:
109
+ words.append(word)
110
+ word = ""
111
+ return words + [word] if word else words
112
+
113
+
114
+ # How many times this worker has reached each step.
115
+ _reached = Counter()
116
+
117
+
118
+ def _crash_if(step):
119
+ _reached[step] += 1
120
+ crash_step, _, nth = os.environ.get(CRASH_ENV, "").partition(":")
121
+ if crash_step == step and _reached[step] == int(nth or 1):
122
+ os.kill(os.getpid(), signal.SIGKILL)
@@ -0,0 +1,57 @@
1
+ """Runs Models through mlx-audio on Apple Silicon. Imported only inside the worker (ADR 0002)."""
2
+
3
+ from typing import Any
4
+
5
+ import mlx.core as mx
6
+ import numpy as np
7
+ from mlx_audio.stt.utils import load as load_stt
8
+ from mlx_audio.tts.utils import load as load_tts
9
+
10
+ from voxx.output import BUG_HINT, VoxxError
11
+
12
+ # Narrations name languages by BCP 47 tag; mlx-audio names them in English.
13
+ LANGUAGE_NAMES = {"zh": "chinese", "en": "english", "ja": "japanese", "ko": "korean", "fr": "french", "de": "german",
14
+ "it": "italian", "pt": "portuguese", "ru": "russian", "es": "spanish", "ar": "arabic", "id": "indonesian",
15
+ "th": "thai", "vi": "vietnamese", "tr": "turkish", "hi": "hindi", "ms": "malay", "nl": "dutch",
16
+ "sv": "swedish", "da": "danish", "fi": "finnish", "pl": "polish", "cs": "czech", "fa": "persian",
17
+ "el": "greek", "ro": "romanian", "hu": "hungarian", "mk": "macedonian", "yue": "cantonese",
18
+ "fil": "filipino"}
19
+
20
+
21
+ class MlxAudioEngine:
22
+ # mlx-audio's Models share no typed interface.
23
+ model: Any
24
+
25
+ def load(self, path, capability):
26
+ # mlx-audio loads Models that speak and Models that listen through different loaders.
27
+ self.model = load_stt(path) if capability in ("transcribe", "align") else load_tts(path)
28
+
29
+ def synthesize(self, text, *, model, voice, style, language, seed):
30
+ return self._read(model, seed, text=text, voice=voice, instruct=style, lang_code=LANGUAGE_NAMES[language])
31
+
32
+ def clone(self, text, *, model, reference, reference_text, language, seed):
33
+ # Qwen3-TTS Base clones from ref_audio and ref_text. VoxCPM2 takes ref_audio for the voice and continues
34
+ # prompt_audio, read as prompt_text, for its closest likeness (upstream's "ultimate cloning"). Each Model's
35
+ # generate ignores the other's arguments.
36
+ return self._read(model, seed, text=text, ref_audio=str(reference), ref_text=reference_text,
37
+ prompt_audio=str(reference), prompt_text=reference_text, lang_code=LANGUAGE_NAMES[language])
38
+
39
+ def _read(self, model, seed, **arguments):
40
+ # mlx samples from one global generator, so seeding it before each Line makes the Line independent of the others.
41
+ mx.random.seed(seed)
42
+ # CustomVoice Models never split a Line; an empty split_pattern keeps the Base ones from splitting it at newlines.
43
+ results = list(self.model.generate(**arguments, split_pattern="", verbose=False))
44
+ if len(results) != 1:
45
+ raise VoxxError("engine_split_line", f"mlx-audio read the Line in {len(results)} parts instead of one.",
46
+ BUG_HINT, {"model": model, "parts": len(results)})
47
+ return np.asarray(results[0].audio, dtype=np.float32), results[0].sample_rate
48
+
49
+ def transcribe(self, path, *, model, language):
50
+ # Greedy decoding, so the same Line is always heard the same way; the path lets mlx-audio resample to 16 kHz.
51
+ # No language lets Qwen3-ASR detect it.
52
+ return self.model.generate(str(path), language=language and LANGUAGE_NAMES[language], verbose=False).text
53
+
54
+ def align(self, path, *, model, text, language):
55
+ # The aligner splits the text into words itself: by character for Chinese, by space for English.
56
+ result = self.model.generate(str(path), text=text, language=LANGUAGE_NAMES[language])
57
+ return [(item.text, item.start_time, item.end_time) for item in result]
voxx/files.py ADDED
@@ -0,0 +1,34 @@
1
+ """Files and directories: writing so a reader never sees a half-written file, and finding where a path would live."""
2
+
3
+ import shutil
4
+ import tempfile
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+
8
+
9
+ @contextmanager
10
+ def replacing(path):
11
+ """Yields a temporary path next to `path`; on success it replaces `path`, on failure it is removed."""
12
+ partial = path.with_name(path.name + ".partial")
13
+ try:
14
+ yield partial
15
+ except BaseException:
16
+ partial.unlink(missing_ok=True)
17
+ raise
18
+ partial.replace(path)
19
+
20
+
21
+ @contextmanager
22
+ def staging(directory):
23
+ """Yields a new hidden directory inside `directory`, so a file written there moves into `directory` with one atomic
24
+ rename; on the way out it is removed with whatever is still in it."""
25
+ stage = Path(tempfile.mkdtemp(prefix=".voxx-staging-", dir=directory))
26
+ try:
27
+ yield stage
28
+ finally:
29
+ shutil.rmtree(stage, ignore_errors=True)
30
+
31
+
32
+ def nearest_existing(path):
33
+ """`path` if it exists, else its closest existing parent: where its disk space would come from."""
34
+ return next(p for p in (path, *path.parents) if p.exists())