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 +1 -0
- voxx/__main__.py +5 -0
- voxx/audio.py +102 -0
- voxx/cli.py +89 -0
- voxx/doctor.py +49 -0
- voxx/engines/__init__.py +47 -0
- voxx/engines/fake.py +122 -0
- voxx/engines/mlx_audio.py +57 -0
- voxx/files.py +34 -0
- voxx/install.py +176 -0
- voxx/models.py +277 -0
- voxx/narrate.py +200 -0
- voxx/narration.py +116 -0
- voxx/output.py +45 -0
- voxx/schema.py +448 -0
- voxx/self_check.py +49 -0
- voxx/voices.py +190 -0
- voxx/words.py +26 -0
- voxx/worker/__init__.py +1 -0
- voxx/worker/__main__.py +86 -0
- voxx/worker/client.py +93 -0
- voxx-0.1.0.dist-info/METADATA +109 -0
- voxx-0.1.0.dist-info/RECORD +27 -0
- voxx-0.1.0.dist-info/WHEEL +4 -0
- voxx-0.1.0.dist-info/entry_points.txt +2 -0
- voxx-0.1.0.dist-info/licenses/LICENSE +73 -0
- voxx-0.1.0.dist-info/licenses/NOTICE +25 -0
voxx/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""voxx: a local text-to-speech CLI for agents."""
|
voxx/__main__.py
ADDED
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 ""
|
voxx/engines/__init__.py
ADDED
|
@@ -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())
|