Compare commits
No files matched your search
@@ -37,3 +37,6 @@ media-files/video/*.mp4
|
||||
|
||||
# Small public-domain demo evidence clip for the auk voice-editing demo
|
||||
!auk/*.wav
|
||||
|
||||
# VibeVoice 4-speaker spike test clip - committed for remote review
|
||||
!vibevoice-spike/*.mp3
|
||||
@@ -9,6 +9,7 @@ Audio processing demos: WhisperX, Demucs, and TTS technology proofs-of-concept.
|
||||
- `tts/` — text-to-speech service demos (voice cloning via MOSS-TTS)
|
||||
- `auk/` — voice *editing* demos (emotion/pitch/speed/content editing on existing recordings,
|
||||
as opposed to `tts/`'s clone-and-generate approach)
|
||||
- `webui/` — interactive pipeline studio chaining demucs -> AuK -> MOSS-TTS with previews
|
||||
|
||||
Each subdirectory has its own README with setup and run instructions.
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Mastering chain for generated TTS/voice clips: fixes the "tinny" spectral
|
||||
imbalance common to neural vocoder output (excess 3-6kHz presence energy,
|
||||
starved low end relative to a naturally-recorded voice - see the project's
|
||||
spectral comparison: raw recordings skew toward 200-1000Hz, generated clips
|
||||
dump ~35% of their energy into 3-6kHz) and adds the density/space that
|
||||
reads as "produced" rather than dry/synthetic.
|
||||
|
||||
Chain order matters: EQ before compression (so the compressor reacts to
|
||||
the corrected tonal balance, not the raw tinny one), reverb after
|
||||
compression (so the room tail doesn't get squashed), limiter last.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from pedalboard import (
|
||||
Compressor,
|
||||
HighpassFilter,
|
||||
HighShelfFilter,
|
||||
Limiter,
|
||||
LowShelfFilter,
|
||||
PeakFilter,
|
||||
Pedalboard,
|
||||
Reverb,
|
||||
)
|
||||
|
||||
|
||||
def build_chain(
|
||||
warmth_db: float,
|
||||
presence_cut_db: float,
|
||||
air_db: float,
|
||||
comp_ratio: float,
|
||||
reverb_wet: float,
|
||||
) -> Pedalboard:
|
||||
return Pedalboard([
|
||||
HighpassFilter(cutoff_frequency_hz=60),
|
||||
LowShelfFilter(cutoff_frequency_hz=150, gain_db=warmth_db, q=0.7),
|
||||
PeakFilter(cutoff_frequency_hz=3800, gain_db=presence_cut_db, q=1.0),
|
||||
HighShelfFilter(cutoff_frequency_hz=6500, gain_db=air_db, q=0.7),
|
||||
Compressor(threshold_db=-20, ratio=comp_ratio, attack_ms=12, release_ms=120),
|
||||
Reverb(room_size=0.08, damping=0.85, wet_level=reverb_wet, dry_level=1.0 - reverb_wet),
|
||||
Limiter(threshold_db=-1.0, release_ms=100),
|
||||
])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Apply a studio mastering chain to a voice clip")
|
||||
parser.add_argument("--input", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--warmth-db", type=float, default=5.5, help="Low-shelf boost @150Hz")
|
||||
parser.add_argument("--presence-cut-db", type=float, default=-5.0, help="Peak cut @3.8kHz (the tinny band)")
|
||||
parser.add_argument("--air-db", type=float, default=-4.5, help="High-shelf @6.5kHz, for softness")
|
||||
parser.add_argument("--comp-ratio", type=float, default=2.5)
|
||||
parser.add_argument("--reverb-wet", type=float, default=0.025)
|
||||
args = parser.parse_args()
|
||||
|
||||
audio, sr = sf.read(args.input, always_2d=True)
|
||||
audio = audio.T.astype(np.float32) # pedalboard wants (channels, samples)
|
||||
|
||||
board = build_chain(
|
||||
args.warmth_db, args.presence_cut_db, args.air_db, args.comp_ratio, args.reverb_wet
|
||||
)
|
||||
processed = board(audio, sr)
|
||||
|
||||
# pedalboard's Limiter is attack/release-based, not a true brick-wall
|
||||
# limiter - fast transients can still punch through its threshold (seen
|
||||
# in practice: in-memory peaks hit 0dBFS despite threshold_db=-1.0,
|
||||
# producing audible clipping distortion on playback). Hard safety
|
||||
# ceiling here guarantees the written file never clips regardless.
|
||||
target_peak = 10 ** (-1.0 / 20)
|
||||
peak = np.max(np.abs(processed))
|
||||
if peak > target_peak:
|
||||
processed = processed * (target_peak / peak)
|
||||
|
||||
sf.write(args.output, processed.T, sr)
|
||||
print(f"[INFO] Saved {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Stable Audio 3 Small-SFX CLI: generate a sound effect/ambience clip from a
|
||||
text description. Runs on CPU by design - this checkpoint is the
|
||||
"lightweight, no GPU required" model in Stable Audio 3's lineup, so it
|
||||
never contends with ComfyUI/other models for VRAM (unlike generate_sfx.py's
|
||||
MOSS-SoundEffect, which needs the 3090 and ComfyUI's memory freed first).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
import torchaudio
|
||||
from stable_audio_3 import StableAudioModel
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Stable Audio 3 Small-SFX text-to-sound-effect")
|
||||
parser.add_argument("--text", required=True, help="Description of the sound effect/ambience")
|
||||
parser.add_argument("--output", required=True, help="Output wav path")
|
||||
parser.add_argument("--duration", type=float, default=10.0, help="Clip duration in seconds (max 120)")
|
||||
parser.add_argument("--seed", type=int, default=-1, help="Random seed, or -1 for random")
|
||||
args = parser.parse_args()
|
||||
|
||||
model = StableAudioModel.from_pretrained("small-sfx", device="cpu")
|
||||
sample_rate = model.model.sample_rate
|
||||
|
||||
audio = model.generate(prompt=args.text, duration=args.duration, seed=args.seed)
|
||||
torchaudio.save(args.output, audio[0].cpu(), sample_rate)
|
||||
print(f"[INFO] Saved {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Generic MOSS-TTS zero-shot cloning CLI: clone the voice in --reference and
|
||||
say --text in it. Used directly, and as the final stage of webui/app.py.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from transformers import AutoModel, AutoProcessor
|
||||
|
||||
PRETRAINED = "OpenMOSS-Team/MOSS-TTS-Local-Transformer-v1.5"
|
||||
|
||||
|
||||
def resolve_attn_implementation(device: str, dtype: torch.dtype) -> str:
|
||||
if (
|
||||
device == "cuda"
|
||||
and importlib.util.find_spec("flash_attn") is not None
|
||||
and dtype in {torch.float16, torch.bfloat16}
|
||||
):
|
||||
major, _ = torch.cuda.get_device_capability()
|
||||
if major >= 8:
|
||||
return "flash_attention_2"
|
||||
return "sdpa" if device == "cuda" else "eager"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="MOSS-TTS zero-shot voice cloning")
|
||||
parser.add_argument("--text", required=True, help="Text to synthesize")
|
||||
parser.add_argument("--reference", required=True, help="Reference audio path")
|
||||
parser.add_argument("--output", required=True, help="Output wav path")
|
||||
parser.add_argument("--language", default="English", help="Language tag")
|
||||
parser.add_argument(
|
||||
"--instruction", default=None,
|
||||
help='Style/delivery instruction (e.g. "Change the emotion to excited."). '
|
||||
"Undocumented MOSS-TTS field for this checkpoint - confirmed to work for "
|
||||
"emotion in testing, passed through as-is.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed", type=int, default=None,
|
||||
help="Random seed for reproducibility. Unset = nondeterministic.",
|
||||
)
|
||||
parser.add_argument("--audio_temperature", type=float, default=1.7)
|
||||
parser.add_argument("--audio_top_p", type=float, default=0.8)
|
||||
parser.add_argument("--audio_top_k", type=int, default=25)
|
||||
parser.add_argument("--audio_repetition_penalty", type=float, default=1.0)
|
||||
parser.add_argument("--max_new_tokens", type=int, default=4096)
|
||||
args = parser.parse_args()
|
||||
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
dtype = torch.bfloat16 if device == "cuda" else torch.float32
|
||||
|
||||
if args.seed is not None:
|
||||
torch.manual_seed(args.seed)
|
||||
if device == "cuda":
|
||||
torch.cuda.manual_seed_all(args.seed)
|
||||
|
||||
torch.backends.cuda.enable_cudnn_sdp(False)
|
||||
torch.backends.cuda.enable_flash_sdp(True)
|
||||
torch.backends.cuda.enable_mem_efficient_sdp(True)
|
||||
torch.backends.cuda.enable_math_sdp(True)
|
||||
|
||||
attn_implementation = resolve_attn_implementation(device, dtype)
|
||||
print(f"[INFO] attn_implementation={attn_implementation}")
|
||||
|
||||
processor = AutoProcessor.from_pretrained(PRETRAINED, trust_remote_code=True)
|
||||
processor.audio_tokenizer = processor.audio_tokenizer.to(device)
|
||||
|
||||
model = AutoModel.from_pretrained(
|
||||
PRETRAINED,
|
||||
trust_remote_code=True,
|
||||
attn_implementation=attn_implementation,
|
||||
dtype=dtype,
|
||||
).to(device)
|
||||
model.eval()
|
||||
|
||||
conversation = [
|
||||
processor.build_user_message(
|
||||
text=args.text,
|
||||
language=args.language,
|
||||
reference=[args.reference],
|
||||
instruction=args.instruction,
|
||||
)
|
||||
]
|
||||
|
||||
with torch.no_grad():
|
||||
batch = processor([conversation], mode="generation")
|
||||
outputs = model.generate(
|
||||
input_ids=batch["input_ids"].to(device),
|
||||
attention_mask=batch["attention_mask"].to(device),
|
||||
max_new_tokens=args.max_new_tokens,
|
||||
do_sample=True,
|
||||
audio_temperature=args.audio_temperature,
|
||||
audio_top_p=args.audio_top_p,
|
||||
audio_top_k=args.audio_top_k,
|
||||
audio_repetition_penalty=args.audio_repetition_penalty,
|
||||
)
|
||||
message = processor.decode(outputs)[0]
|
||||
audio = message.audio_codes_list[0]
|
||||
data = audio.detach().cpu().to(torch.float32).numpy()
|
||||
if data.ndim == 2:
|
||||
data = data.T
|
||||
sf.write(args.output, data, processor.model_config.sampling_rate)
|
||||
print(f"[INFO] Saved {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
MOSS-SoundEffect CLI: generate an ambient/sound-effect clip from a text
|
||||
description. Sibling of generate.py (MOSS-TTS voice cloning) - same
|
||||
backend setup and model-loading pattern, different checkpoint and a
|
||||
text-only build_user_message call (no reference audio, no cloning).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
# This checkpoint's weights alone use ~22.3GB of this box's ~23.56GB usable
|
||||
# VRAM, leaving generate()'s own small allocations (a vocab-sized mask
|
||||
# tensor, etc.) no room without this - confirmed via OOM otherwise. Must be
|
||||
# set before torch's CUDA allocator initializes, so it's set here rather
|
||||
# than relying on the caller's shell environment.
|
||||
os.environ.setdefault("PYTORCH_ALLOC_CONF", "expandable_segments:True")
|
||||
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
||||
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from transformers import AutoModel, AutoProcessor
|
||||
|
||||
PRETRAINED = "OpenMOSS-Team/MOSS-SoundEffect"
|
||||
|
||||
|
||||
def resolve_attn_implementation(device: str, dtype: torch.dtype) -> str:
|
||||
if (
|
||||
device == "cuda"
|
||||
and importlib.util.find_spec("flash_attn") is not None
|
||||
and dtype in {torch.float16, torch.bfloat16}
|
||||
):
|
||||
major, _ = torch.cuda.get_device_capability()
|
||||
if major >= 8:
|
||||
return "flash_attention_2"
|
||||
return "sdpa" if device == "cuda" else "eager"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="MOSS-SoundEffect text-to-sound-effect")
|
||||
parser.add_argument("--text", required=True, help="Description of the sound effect/ambience")
|
||||
parser.add_argument("--output", required=True, help="Output wav path")
|
||||
parser.add_argument(
|
||||
"--seed", type=int, default=None,
|
||||
help="Random seed for reproducibility. Unset = nondeterministic.",
|
||||
)
|
||||
parser.add_argument("--max_new_tokens", type=int, default=4096)
|
||||
args = parser.parse_args()
|
||||
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
dtype = torch.bfloat16 if device == "cuda" else torch.float32
|
||||
|
||||
if args.seed is not None:
|
||||
torch.manual_seed(args.seed)
|
||||
if device == "cuda":
|
||||
torch.cuda.manual_seed_all(args.seed)
|
||||
|
||||
torch.backends.cuda.enable_cudnn_sdp(False)
|
||||
torch.backends.cuda.enable_flash_sdp(True)
|
||||
torch.backends.cuda.enable_mem_efficient_sdp(True)
|
||||
torch.backends.cuda.enable_math_sdp(True)
|
||||
|
||||
attn_implementation = resolve_attn_implementation(device, dtype)
|
||||
print(f"[INFO] attn_implementation={attn_implementation}")
|
||||
|
||||
processor = AutoProcessor.from_pretrained(PRETRAINED, trust_remote_code=True)
|
||||
processor.audio_tokenizer = processor.audio_tokenizer.to(device)
|
||||
|
||||
model = AutoModel.from_pretrained(
|
||||
PRETRAINED,
|
||||
trust_remote_code=True,
|
||||
attn_implementation=attn_implementation,
|
||||
dtype=dtype,
|
||||
).to(device)
|
||||
model.eval()
|
||||
|
||||
conversation = [processor.build_user_message(ambient_sound=args.text)]
|
||||
|
||||
with torch.no_grad():
|
||||
batch = processor([conversation], mode="generation")
|
||||
# This checkpoint's generate() doesn't accept the TTS model's sampling
|
||||
# kwargs (do_sample/audio_temperature/etc raise TypeError) - matches
|
||||
# the official README example, which only passes these three.
|
||||
outputs = model.generate(
|
||||
input_ids=batch["input_ids"].to(device),
|
||||
attention_mask=batch["attention_mask"].to(device),
|
||||
max_new_tokens=args.max_new_tokens,
|
||||
)
|
||||
message = processor.decode(outputs)[0]
|
||||
audio = message.audio_codes_list[0]
|
||||
data = audio.detach().cpu().to(torch.float32).numpy()
|
||||
if data.ndim == 2:
|
||||
data = data.T
|
||||
sf.write(args.output, data, processor.model_config.sampling_rate)
|
||||
print(f"[INFO] Saved {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,52 @@
|
||||
# VibeVoice (community fork) 4-person podcast spike
|
||||
|
||||
Standalone experiment, not integrated into the webui or the production
|
||||
pipeline. Tests whether VibeVoice (https://github.com/vibevoice-community/VibeVoice)
|
||||
can generate a 4-speaker podcast scene in one pass, cloning our four
|
||||
established character voices (Kevin, Marge, Terry, Priya) from their
|
||||
existing approved `reference_audio` in the-service-station repo.
|
||||
|
||||
- `scripts/trailer_promo_4speaker.txt` - the test script: the real
|
||||
"sausage rolls" trailer scene from `the-service-station/scripts/trailer-promo.md`,
|
||||
converted from our `[Character|tags]` format to VibeVoices required
|
||||
`Speaker N: ...` plain-text format (tags dropped - VibeVoice has no
|
||||
per-line emotion/timing control).
|
||||
- `trailer_promo_4speaker_vibevoice-1.5b.mp3` - the generated result
|
||||
(VibeVoice-1.5B, seed 42). 47s of audio generated in 14.5s (RTF 0.31x).
|
||||
|
||||
Setup lives outside this repo (not committed): `.venv/` here is a fresh
|
||||
isolated venv (`pip install -e /root/projects/VibeVoice`, pinned
|
||||
`transformers==4.51.3`), pointed at the already-downloaded
|
||||
`VibeVoice-1.5B` weights under `/root/projects/ComfyUI/models/tts/VibeVoice/`.
|
||||
|
||||
## Findings (2026-09-30)
|
||||
|
||||
Whisper-transcript check against the script: mostly faithful, not
|
||||
word-perfect. Two small unscripted insertions ("I am here!" before Priyas
|
||||
line, "For me," before Marges reply) and one substitution ("He will." ->
|
||||
"You will."). No repetition/collapse like the AuK chain-corruption bug -
|
||||
just minor ad-libbing, not catastrophic.
|
||||
|
||||
No audible AI disclaimer or obvious background music in this run - both
|
||||
are documented risks for this model family (see
|
||||
`the-service-station/research/vibevoice-2026-08-21.md` and
|
||||
`vibevoice-community-fork-2026-08-21.md`), so treat this one clean run as
|
||||
encouraging, not conclusive.
|
||||
|
||||
Steves call after listening: pursue 7B (weights already downloaded, same
|
||||
venv/script, swap `--model_path`) or not worth it given the limitations
|
||||
the prior research already flagged.
|
||||
|
||||
## 7B result (2026-09-30, same script/seed/voices)
|
||||
|
||||
`trailer_promo_4speaker_vibevoice-7b.mp3` - VibeVoice-7B, same seed (42),
|
||||
same script, same reference voices. 34.8s of audio generated in 22.2s
|
||||
(RTF 0.64x - still faster than real-time, just less headroom than 1.5B).
|
||||
|
||||
Whisper-transcript check: cleaner than 1.5B - neither of 1.5Bs unscripted
|
||||
insertions ("I am here!", "For me,") appeared, and "He will." came through
|
||||
exactly right this time (1.5B had drifted it to "You will."). One real
|
||||
deviation: Marges defensive line "I get one fact wrong per episode."
|
||||
came out as a question, "Can I get one fact wrong per episode?" - changes
|
||||
the delivery/meaning, not just wording. Still no audible disclaimer and
|
||||
nothing that reads as injected background music.
|
||||
@@ -0,0 +1,9 @@
|
||||
Speaker 1: The Service Station. Three facts, one car park, and absolutely no reason to be in a hurry.
|
||||
Speaker 2: Except for me, obviously. I'm always in a hurry.
|
||||
Speaker 3: I had a Sinclair Spectrum in 1984, and I will find a way to bring it up.
|
||||
Speaker 1: He will.
|
||||
Speaker 4: I still don't understand why the sausage rolls are relevant, but here we are.
|
||||
Speaker 2: They're always relevant, Priya.
|
||||
Speaker 1: Every week: one underdog, one argument, and one fact Marge gets wrong.
|
||||
Speaker 2: I get one fact wrong per episode. Tops.
|
||||
Speaker 3: It's usually about football.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,56 @@
|
||||
# webui
|
||||
|
||||
A pipeline studio: strip a voice with **demucs**, edit it with **AuK** (emotion, pitch, speed,
|
||||
free-form instructions — with preview and undo/reset), then generate new speech in that voice
|
||||
with **MOSS-TTS**.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Manually chaining demucs → AuK → MOSS-TTS across three separate venvs (via SSH one-off scripts)
|
||||
worked but made it slow to experiment — especially with AuK's pitch/emotion controls, where you
|
||||
really want to preview a result and back out if it's wrong (see the pitch-overshoot findings in
|
||||
`auk/README.md`) rather than committing to a specific edit blind.
|
||||
|
||||
## Architecture
|
||||
|
||||
A single lightweight Gradio app (`app.py`) that **shells out** to each tool's own isolated venv
|
||||
via subprocess, rather than merging their dependencies into one environment — MOSS-TTS pins
|
||||
`transformers==5.0.0`, AuK needs `4.57.6`; they can't coexist in one venv. This app's own venv
|
||||
only needs `gradio` itself.
|
||||
|
||||
Each stage's output becomes the next stage's input by filesystem path; a `history` list backs
|
||||
the undo/reset buttons in the voice editor.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv && . .venv/bin/activate
|
||||
pip install gradio
|
||||
```
|
||||
|
||||
Assumes `demucs/`, `auk/`, and `tts/` (siblings in this repo) already have their own working
|
||||
venvs and, for `auk/`, downloaded model weights — see their READMEs.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
Serves on `0.0.0.0:7860`. The app automatically calls ComfyUI's `/free` endpoint (if its queue
|
||||
is empty) before each heavy step, to reduce GPU contention on a shared box — see the DNS/routing
|
||||
and GPU-contention notes earlier in this project's history for why that matters here.
|
||||
|
||||
## Known limitations (inherited from the underlying tools)
|
||||
|
||||
- **AuK's real usable clip length is ~25-30s**, not several minutes — feeding it a long recording
|
||||
will OOM even with `--cpu_offload` (see `auk/README.md`). This app doesn't chunk long inputs
|
||||
automatically; keep reference clips short.
|
||||
- **AuK's pitch-editing instruction-following is unreliable** — even "1 semitone" has measured
|
||||
out at ~4+ semitones of actual shift in testing. The UI surfaces a warning; preview before
|
||||
trusting a pitch edit.
|
||||
- Each stage is a blocking subprocess call (model load + inference each time) — the UI will
|
||||
appear to hang for anywhere from ~10s to a couple of minutes per click, with no progress bar
|
||||
yet.
|
||||
- No queueing/locking across browser tabs or concurrent users — this is a single-GPU box, so two
|
||||
simultaneous generations will contend for VRAM.
|
||||
+1106
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user