Add exit tone (roger beep) and standby tone after speech
Queue-aware exit tones: descending roger beep when queue empties
("over"), ascending standby blip when more items are queued ("standby,
more coming"). Includes quindar-out (2475 Hz) as Apollo-themed
alternative. Configurable via TTS_EXIT_TONE env var.
Also adds CLAUDE.md documenting the full tone system.
This commit is contained in:
parent
25de529bf4
commit
6a81e0760a
61
CLAUDE.md
Normal file
61
CLAUDE.md
Normal file
@ -0,0 +1,61 @@
|
||||
# TTS MCP Server
|
||||
|
||||
Multi-engine text-to-speech server exposed via FastMCP 3.0 Streamable HTTP. Engines: Kokoro (ONNX), Piper (Wyoming/Docker), Orpheus (llama-server + SNAC).
|
||||
|
||||
## Build & Run
|
||||
|
||||
```bash
|
||||
make up # build + start (docker compose)
|
||||
make logs # follow logs
|
||||
make restart # restart containers
|
||||
make status # show running containers + health
|
||||
```
|
||||
|
||||
## Entry & Exit Tones (Beep System)
|
||||
|
||||
Queued speech playback (`speak()`) is bookended by short alert tones. `generate_audio()` is unaffected (file-only, no playback).
|
||||
|
||||
### Tone Positions
|
||||
|
||||
| Position | When | Purpose |
|
||||
|----------|------|---------|
|
||||
| **Entry tone** | Before speech starts | "Incoming transmission" alert |
|
||||
| **Exit tone** | After speech, queue empty | "Over and out" — channel clear |
|
||||
| **Standby tone** | After speech, more queued | "Standby" — more messages coming |
|
||||
|
||||
### Available Tones
|
||||
|
||||
| Name | Frequency | Duration | Inspired by |
|
||||
|------|-----------|----------|-------------|
|
||||
| `chirp` | 1800 Hz | ~144 ms | Nextel iDEN Talk Permit Tone (TPT) — the 24/24/24/24/48 ms on/off pattern |
|
||||
| `apollo` | 2525 Hz | 250 ms | NASA quindar intro (key-up) tone used during Apollo missions |
|
||||
| `roger` | 1400-1000 Hz | ~100 ms | Classic CB radio descending two-tone roger beep |
|
||||
| `quindar-out` | 2475 Hz | 250 ms | NASA quindar unkey tone (distinct frequency from intro) |
|
||||
| `standby` | 1000-1400 Hz | ~60 ms | Ascending blip — inverse of roger, signals "more coming" |
|
||||
|
||||
### Configuration
|
||||
|
||||
```env
|
||||
TTS_ENTRY_TONE=chirp # before speech (chirp, apollo, none, or /path/to/custom.wav)
|
||||
TTS_EXIT_TONE=roger # after speech, queue empty (roger, quindar-out, none, or path)
|
||||
```
|
||||
|
||||
The standby tone is always the built-in ascending blip. It plays instead of the exit tone when more items are queued.
|
||||
|
||||
Tones are generated programmatically at startup (48kHz, 16-bit PCM, -3 dB headroom) in `tones.py` using numpy. No bundled audio assets.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `server.py` — FastMCP lifespan, tool definitions, engine setup
|
||||
- `queue.py` — Producer-consumer speech queue with priority tiers
|
||||
- `tones.py` — Tone WAV generator (entry/exit/standby)
|
||||
- `audio.py` — WAV writing and `pw-play` async wrapper
|
||||
- `settings.py` — Pydantic settings from env vars (prefix: `TTS_`)
|
||||
- `engines/` — TTSEngine implementations (kokoro, piper, orpheus)
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- Speech queue is serialized (one playback at a time) but synthesis is parallel
|
||||
- Tones are non-fatal: if `pw-play` fails on a tone, speech still plays
|
||||
- Orpheus uses llama-server (not Ollama) for 15x throughput via continuous batching
|
||||
- SNAC decoder is lazy-loaded on first Orpheus call to reduce idle memory
|
||||
@ -52,7 +52,11 @@ class SpeechQueue:
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, max_depth: int = MAX_QUEUE_DEPTH, entry_tone: Path | None = None
|
||||
self,
|
||||
max_depth: int = MAX_QUEUE_DEPTH,
|
||||
entry_tone: Path | None = None,
|
||||
exit_tone: Path | None = None,
|
||||
standby_tone: Path | None = None,
|
||||
) -> None:
|
||||
self._queue: asyncio.PriorityQueue[_WorkItem] = asyncio.PriorityQueue(
|
||||
maxsize=max_depth
|
||||
@ -64,6 +68,8 @@ class SpeechQueue:
|
||||
self._max_depth = max_depth
|
||||
self._stopped = False
|
||||
self._entry_tone = entry_tone
|
||||
self._exit_tone = exit_tone
|
||||
self._standby_tone = standby_tone
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._counter += 1
|
||||
@ -150,6 +156,18 @@ class SpeechQueue:
|
||||
item.result.audio_path,
|
||||
expected_seconds=item.result.duration_seconds,
|
||||
)
|
||||
|
||||
# Play exit tone — "standby" if more queued, "roger" if done
|
||||
exit = (
|
||||
self._standby_tone if self._queue.qsize() > 0
|
||||
else self._exit_tone
|
||||
)
|
||||
if exit:
|
||||
try:
|
||||
await play_audio(exit, expected_seconds=0.3)
|
||||
except PlaybackError:
|
||||
pass # Non-fatal
|
||||
|
||||
if not item.future.done():
|
||||
item.future.set_result({
|
||||
"played": True,
|
||||
|
||||
@ -15,7 +15,7 @@ from .engines.orpheus import OrpheusEngine
|
||||
from .engines.piper import PiperEngine
|
||||
from .queue import Priority, SpeechQueue
|
||||
from .settings import settings
|
||||
from .tones import generate_tones, resolve_entry_tone
|
||||
from .tones import generate_tones, resolve_tone
|
||||
|
||||
ENGINE_NAMES = Literal["piper", "kokoro", "orpheus"]
|
||||
|
||||
@ -53,17 +53,20 @@ async def app_lifespan(server: FastMCP):
|
||||
health = await eng.check_health()
|
||||
print(f" {name}: {health['status']}", file=sys.stderr)
|
||||
|
||||
# Generate entry tones and resolve which one to use
|
||||
# Generate tones and resolve which ones to use
|
||||
tone_paths = generate_tones(settings.output_dir)
|
||||
entry_tone = resolve_entry_tone(settings.entry_tone, tone_paths)
|
||||
entry_tone = resolve_tone(settings.entry_tone, tone_paths, "entry_tone", default="chirp")
|
||||
exit_tone = resolve_tone(settings.exit_tone, tone_paths, "exit_tone", default="roger")
|
||||
standby_tone = tone_paths.get("standby")
|
||||
|
||||
queue = SpeechQueue(entry_tone=entry_tone)
|
||||
queue = SpeechQueue(entry_tone=entry_tone, exit_tone=exit_tone, standby_tone=standby_tone)
|
||||
queue.start()
|
||||
|
||||
tone_label = settings.entry_tone if entry_tone else "none"
|
||||
entry_label = settings.entry_tone if entry_tone else "none"
|
||||
exit_label = settings.exit_tone if exit_tone else "none"
|
||||
print(
|
||||
f"TTS MCP server ready on {settings.host}:{settings.port} "
|
||||
f"with {len(engines)} engines, entry_tone={tone_label}",
|
||||
f"with {len(engines)} engines, tones={entry_label}/{exit_label}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
@ -32,6 +32,10 @@ class Settings(BaseSettings):
|
||||
# Entry tone before speech: "chirp", "apollo", "none", or path to custom WAV
|
||||
entry_tone: str = "chirp"
|
||||
|
||||
# Exit tone after speech: "roger", "quindar-out", "none", or path to custom WAV
|
||||
# When more items are queued, plays "standby" (ascending blip) instead
|
||||
exit_tone: str = "roger"
|
||||
|
||||
@property
|
||||
def blacklisted_voices(self) -> set[str]:
|
||||
return {v.strip().lower() for v in self.voice_blacklist.split(",") if v.strip()}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Entry tone generator — short alert tones played before speech.
|
||||
"""Tone generator — short alert tones played before/after speech.
|
||||
|
||||
Generates WAV files programmatically using numpy. Tones are written once
|
||||
at startup and reused for every queued playback.
|
||||
@ -49,6 +49,29 @@ def _build_apollo() -> np.ndarray:
|
||||
return _sine_segment(2525, 250, fade_ms=5.0)
|
||||
|
||||
|
||||
def _build_roger() -> np.ndarray:
|
||||
"""Classic descending two-tone roger beep: 1400 Hz -> 1000 Hz."""
|
||||
segments = [
|
||||
_sine_segment(1400, 40),
|
||||
_sine_segment(1000, 60),
|
||||
]
|
||||
return np.concatenate(segments)
|
||||
|
||||
|
||||
def _build_quindar_out() -> np.ndarray:
|
||||
"""Quindar unkey tone: 2475 Hz, 250 ms with 5 ms fade in/out."""
|
||||
return _sine_segment(2475, 250, fade_ms=5.0)
|
||||
|
||||
|
||||
def _build_standby() -> np.ndarray:
|
||||
"""Quick ascending blip — "more coming, standby"."""
|
||||
segments = [
|
||||
_sine_segment(1000, 30),
|
||||
_sine_segment(1400, 30),
|
||||
]
|
||||
return np.concatenate(segments)
|
||||
|
||||
|
||||
def _write_tone(samples: np.ndarray, path: Path) -> Path:
|
||||
"""Write float32 samples to 16-bit PCM WAV."""
|
||||
peak = max(abs(samples.max()), abs(samples.min()), 1e-8)
|
||||
@ -63,27 +86,40 @@ def _write_tone(samples: np.ndarray, path: Path) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
_TONE_BUILDERS = {
|
||||
"chirp": _build_chirp,
|
||||
"apollo": _build_apollo,
|
||||
"roger": _build_roger,
|
||||
"quindar-out": _build_quindar_out,
|
||||
"standby": _build_standby,
|
||||
}
|
||||
|
||||
|
||||
def generate_tones(output_dir: Path) -> dict[str, Path]:
|
||||
"""Generate entry tone WAV files. Returns {name: path} mapping."""
|
||||
"""Generate all tone WAV files. Returns {name: path} mapping."""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
tones = {}
|
||||
|
||||
for name, builder in [("chirp", _build_chirp), ("apollo", _build_apollo)]:
|
||||
for name, builder in _TONE_BUILDERS.items():
|
||||
path = output_dir / f"_tone-{name}.wav"
|
||||
if not path.exists():
|
||||
_write_tone(builder(), path)
|
||||
print(f" Generated entry tone: {path.name}", file=sys.stderr)
|
||||
print(f" Generated tone: {path.name}", file=sys.stderr)
|
||||
tones[name] = path
|
||||
|
||||
return tones
|
||||
|
||||
|
||||
def resolve_entry_tone(setting: str, tone_paths: dict[str, Path]) -> Path | None:
|
||||
"""Resolve the entry_tone setting to a WAV file path or None.
|
||||
def resolve_tone(
|
||||
setting: str, tone_paths: dict[str, Path], label: str, default: str | None = None
|
||||
) -> Path | None:
|
||||
"""Resolve a tone setting to a WAV file path or None.
|
||||
|
||||
Args:
|
||||
setting: "chirp", "apollo", "none", or a file path.
|
||||
setting: Built-in tone name, "none", or a file path.
|
||||
tone_paths: Mapping from generate_tones().
|
||||
label: Setting name for warning messages (e.g. "entry_tone").
|
||||
default: Fallback tone name if setting not found. None = no fallback.
|
||||
"""
|
||||
if setting == "none":
|
||||
return None
|
||||
@ -96,8 +132,12 @@ def resolve_entry_tone(setting: str, tone_paths: dict[str, Path]) -> Path | None
|
||||
if custom.exists():
|
||||
return custom
|
||||
|
||||
print(
|
||||
f" Warning: entry_tone '{setting}' not found, falling back to chirp",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return tone_paths.get("chirp")
|
||||
fallback = tone_paths.get(default) if default else None
|
||||
if fallback:
|
||||
print(
|
||||
f" Warning: {label} '{setting}' not found, falling back to {default}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(f" Warning: {label} '{setting}' not found, disabled", file=sys.stderr)
|
||||
return fallback
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user