Initial standalone morshu TTS server extracted from the ttstoy bot cog
Self-contained HTTP service with engine, data, start script, systemd unit, and documentation. Runs independently of the Discord bot on its fixed port.
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
# Port the Morshu TTS server listens on
|
||||||
|
PORT=33002
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
*.log
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
# Installing the Morshu TTS Server as a system service
|
||||||
|
|
||||||
|
These steps install the server as a systemd service that starts on boot and
|
||||||
|
restarts automatically if it crashes. Commands that need root are shown with
|
||||||
|
`sudo`; run them in your own terminal.
|
||||||
|
|
||||||
|
## 1. Install prerequisites
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install python3 python3-venv
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Create the virtual environment
|
||||||
|
|
||||||
|
The first run of `start.sh` creates a local venv and installs the Python
|
||||||
|
dependencies from requirements.txt. You can prime it now:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/owen/morshu-tts-server
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop it with Ctrl+C once you see the ready message, then install the unit.
|
||||||
|
|
||||||
|
## 3. Install the systemd unit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp /home/owen/morshu-tts-server/morshu-tts-server.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now morshu-tts-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status morshu-tts-server
|
||||||
|
curl -s http://127.0.0.1:33002/health
|
||||||
|
```
|
||||||
|
|
||||||
|
The health endpoint should return `ok`.
|
||||||
|
|
||||||
|
## Managing the service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart morshu-tts-server
|
||||||
|
sudo systemctl stop morshu-tts-server
|
||||||
|
journalctl -u morshu-tts-server -f
|
||||||
|
```
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Morshu TTS Server
|
||||||
|
|
||||||
|
A standalone HTTP server that generates speech in the voice of Morshu (from the
|
||||||
|
Zelda CD-i games) using phoneme matching against sampled voice lines. It is
|
||||||
|
based on MorshuTalk by jalenluorion (https://github.com/jalenluorion/MorshuTalk).
|
||||||
|
|
||||||
|
This service was extracted from the ttstoy Discord bot cog so it can run on its
|
||||||
|
own as a system service, independent of the bot.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Input text is converted to phonemes with a grapheme-to-phoneme model, then the
|
||||||
|
engine splices together matching phoneme segments taken from a sample recording
|
||||||
|
(`morshutalk_morshu.wav`) to build the output audio.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.9 or newer
|
||||||
|
- Python packages: `numpy`, `pydub`, `nltk`, `g2p_en` (see requirements.txt)
|
||||||
|
- The bundled `morshutalk_morshu.wav` sample (included in this repo)
|
||||||
|
|
||||||
|
On first import, NLTK downloads a few small data packages
|
||||||
|
(`averaged_perceptron_tagger_eng`, `cmudict`, `punkt`, `punkt_tab`).
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`start.sh` creates a local virtual environment on first launch, installs the
|
||||||
|
dependencies from requirements.txt, and starts the server. The listening port
|
||||||
|
defaults to `33002` and can be overridden with the `PORT` environment variable.
|
||||||
|
|
||||||
|
You can also run it directly against an existing interpreter that has the
|
||||||
|
dependencies installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=33002 python3 server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### GET /say
|
||||||
|
|
||||||
|
Generate speech and return a WAV file.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /say?text=Hello%20it%20is%20me%20Morshu
|
||||||
|
```
|
||||||
|
|
||||||
|
Response: `audio/wav` (WAV bytes).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://127.0.0.1:33002/say?text=lamp+oil+rope+bombs" -o morshu.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /health
|
||||||
|
|
||||||
|
Returns `ok`.
|
||||||
|
|
||||||
|
## Running as a system service
|
||||||
|
|
||||||
|
A systemd unit file is provided (`morshu-tts-server.service`). See INSTALL.md
|
||||||
|
for setup steps.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Morshu TTS Server
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=owen
|
||||||
|
WorkingDirectory=/home/owen/morshu-tts-server
|
||||||
|
Environment=PORT=33002
|
||||||
|
ExecStart=/home/owen/morshu-tts-server/start.sh
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""
|
||||||
|
Bundled MorshuTalk engine for ttstoy.
|
||||||
|
Based on MorshuTalk by jalenluorion (https://github.com/jalenluorion/MorshuTalk)
|
||||||
|
Generates speech audio from Morshu's voice lines using phoneme matching.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from os import path
|
||||||
|
from typing import List, Tuple, Callable, Literal
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import random
|
||||||
|
import warnings
|
||||||
|
from pydub import AudioSegment
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# G2P (Grapheme-to-Phoneme) wrapper with progress + cancel support
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
import nltk
|
||||||
|
for _res in ('averaged_perceptron_tagger_eng', 'cmudict', 'punkt', 'punkt_tab'):
|
||||||
|
try:
|
||||||
|
nltk.data.find(f'taggers/{_res}' if 'tagger' in _res else _res)
|
||||||
|
except LookupError:
|
||||||
|
nltk.download(_res, quiet=True)
|
||||||
|
|
||||||
|
from g2p_en.g2p import G2p, unicode, normalize_numbers, word_tokenize, pos_tag
|
||||||
|
|
||||||
|
|
||||||
|
class G2pProgress(G2p):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.cancelled = False
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self.cancelled = True
|
||||||
|
|
||||||
|
def run_with_progress(self, text, callback: Callable[[int, int], None] = None):
|
||||||
|
self.cancelled = False
|
||||||
|
|
||||||
|
text = unicode(text)
|
||||||
|
text = normalize_numbers(text)
|
||||||
|
text = ''.join(char for char in unicodedata.normalize('NFD', text)
|
||||||
|
if unicodedata.category(char) != 'Mn')
|
||||||
|
text = text.lower()
|
||||||
|
text = re.sub("[^ a-z'.,?!\\-]", "", text)
|
||||||
|
text = text.replace("i.e.", "that is")
|
||||||
|
text = text.replace("e.g.", "for example")
|
||||||
|
|
||||||
|
words = word_tokenize(text)
|
||||||
|
tokens = pos_tag(words)
|
||||||
|
|
||||||
|
step = 0
|
||||||
|
total = len(tokens)
|
||||||
|
prons = []
|
||||||
|
|
||||||
|
for word, pos in tokens:
|
||||||
|
if self.cancelled:
|
||||||
|
return
|
||||||
|
|
||||||
|
if callback:
|
||||||
|
callback(step, total)
|
||||||
|
step += 1
|
||||||
|
|
||||||
|
if re.search("[a-z]", word) is None:
|
||||||
|
pron = [word]
|
||||||
|
elif word in self.homograph2features:
|
||||||
|
pron1, pron2, pos1 = self.homograph2features[word]
|
||||||
|
pron = pron1 if pos.startswith(pos1) else pron2
|
||||||
|
elif word in self.cmu:
|
||||||
|
pron = self.cmu[word][0]
|
||||||
|
else:
|
||||||
|
pron = self.predict(word)
|
||||||
|
|
||||||
|
prons.extend(pron)
|
||||||
|
prons.extend([" "])
|
||||||
|
|
||||||
|
if callback:
|
||||||
|
callback(total, total)
|
||||||
|
|
||||||
|
return prons[:-1]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Singleton G2P instance and audio data
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
g2p = G2pProgress()
|
||||||
|
|
||||||
|
_WAV_PATH = path.join(path.dirname(__file__), 'morshutalk_morshu.wav')
|
||||||
|
morshu_wav = AudioSegment.from_wav(_WAV_PATH)
|
||||||
|
|
||||||
|
# Phoneme timing record from the morshu audio
|
||||||
|
morshu_rec = np.rec.array([
|
||||||
|
('', 160, 0), ('L', 250, 2), ('AE', 348, 2), ('M', 420, 2), ('P', 510, 1),
|
||||||
|
('OY', 700, 2), ('L', 835, 1), ('', 1090, 0),
|
||||||
|
('R', 1180, 2), ('OW', 1300, 2), ('', 1390, 0), ('P', 1490, 2), ('', 1850, 0),
|
||||||
|
('B', 1895, 2), ('AA', 2090, 2), ('M', 2235, 2), ('Z', 2390, 2),
|
||||||
|
('', 2780, 0), ('Y', 2840, 2), ('UW', 2960, 2),
|
||||||
|
('W', 3030, 2), ('AA', 3110, 2), ('N', 3150, 1), ('IH', 3240, 2), ('T', 3370, 2), ('', 3810, 0),
|
||||||
|
('IH', 3960, 2), ('T', 4070, 2), ('Y', 4260, 2), ('UH', 4400, 2), ('R', 4510, 2), ('Z', 4600, 2),
|
||||||
|
('M', 4675, 2), ('AY', 4810, 2), ('', 4885, 0),
|
||||||
|
('F', 4930, 2), ('R', 4980, 2), ('EH', 5100, 2), ('N', 5240, 2), ('D', 5300, 2), ('', 5520, 0),
|
||||||
|
('AE', 5630, 2), ('Z', 5740, 2), ('L', 5870, 2), ('AO', 6000, 2), ('NG', 6140, 2),
|
||||||
|
('AE', 6170, 1), ('Z', 6265, 2), ('Y', 6300, 2), ('UW', 6380, 2),
|
||||||
|
('HH', 6450, 2), ('AE', 6510, 1), ('V', 6580, 2),
|
||||||
|
('IH', 6640, 2), ('N', 6670, 2), ('AH', 6747, 2), ('F', 6855, 2),
|
||||||
|
('R', 6960, 2), ('UW', 7060, 2), ('B', 7170, 1), ('IY', 7340, 2), ('Z', 7520, 2), ('', 8236, 0),
|
||||||
|
('S', 8407, 2), ('AA', 8495, 2), ('R', 8570, 2), ('IY', 8630, 1),
|
||||||
|
('L', 8740, 2), ('IH', 8811, 2), ('NG', 8942, 2), ('K', 9014, 2), ('', 9251, 0),
|
||||||
|
('AY', 9384, 2), ('', 9467, 0), ('K', 9512, 2), ('AE', 9640, 2), ('N', 9716, 2), ('', 9844, 0),
|
||||||
|
('G', 9894, 2), ('IH', 9985, 2), ('V', 10060, 2), ('', 10149, 0),
|
||||||
|
('K', 10256, 2), ('R', 10297, 2), ('EH', 10383, 2), ('IH', 10482, 1), ('', 10564, 0), ('T', 10617, 2),
|
||||||
|
('', 10962, 0), ('K', 11019, 2), ('AH', 11100, 2), ('M', 11229, 2), ('B', 11246, 2), ('AE', 11369, 2),
|
||||||
|
('', 11511, 0), ('W', 11590, 2), ('EH', 11622, 1), ('N', 11705, 2),
|
||||||
|
('Y', 11755, 2), ('UH', 11808, 2), ('R', 11864, 2), ('AH', 11959, 2),
|
||||||
|
('L', 12095, 2), ('IH', 12202, 2), ('L', 12386, 2),
|
||||||
|
('', 12596, 0), ('M', 12748, 2), ('M', 12888, 2), ('M', 13037, 2), ('M', 13196, 2), ('', 13426, 0),
|
||||||
|
('R', 13494, 2), ('IH', 13589, 2), ('', 13632, 0), ('CH', 13773, 2), ('ER', 13991, 2), ('', 13992, 0)
|
||||||
|
], names=('phoneme', 'timing', 'priority'))
|
||||||
|
|
||||||
|
similar_phonemes = {
|
||||||
|
'AW': ['AE', 'UW'],
|
||||||
|
'DH': ['D'],
|
||||||
|
'EY': ['EH', 'IY'],
|
||||||
|
'JH': ['CH'],
|
||||||
|
'SH': ['CH'],
|
||||||
|
'TH': ['D'],
|
||||||
|
'ZH': ['CH'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Morshu TTS Engine
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class Morshu:
|
||||||
|
def __init__(self):
|
||||||
|
self.input_str = ""
|
||||||
|
self.input_phonemes = []
|
||||||
|
self.stop_chars = '.,?!:;()\n'
|
||||||
|
self.space_length = 20
|
||||||
|
self.stop_length = 100
|
||||||
|
self.use_phoneme_priority = True
|
||||||
|
self.out_audio = AudioSegment.empty()
|
||||||
|
self.audio_segment_timings = np.rec.array((0, 0), names=('output', 'morshu'))
|
||||||
|
self.canceled = False
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
g2p.cancel()
|
||||||
|
self.canceled = True
|
||||||
|
|
||||||
|
def load_text(self, text: str = None, progress_callback: Callable[[int, int, int], None] = None) \
|
||||||
|
-> AudioSegment | Literal[False]:
|
||||||
|
"""Generate audio from text. Returns AudioSegment or False if cancelled."""
|
||||||
|
self.canceled = False
|
||||||
|
|
||||||
|
if progress_callback is None:
|
||||||
|
progress_callback = lambda major_step, minor_step, minor_total: None
|
||||||
|
|
||||||
|
if text is None:
|
||||||
|
text = self.input_str
|
||||||
|
self.input_str = text
|
||||||
|
text = text.replace('\n', ',,,')
|
||||||
|
|
||||||
|
phonemes = g2p.run_with_progress(text, lambda step, total: progress_callback(0, step, total))
|
||||||
|
if g2p.cancelled:
|
||||||
|
return False
|
||||||
|
|
||||||
|
progress_step = 0
|
||||||
|
progress_total = len(phonemes)
|
||||||
|
|
||||||
|
output = AudioSegment.empty().set_frame_rate(morshu_wav.frame_rate)
|
||||||
|
audio_out_millis = []
|
||||||
|
audio_morshu_millis = []
|
||||||
|
phoneme_segment = []
|
||||||
|
|
||||||
|
while len(phonemes) > 0:
|
||||||
|
if self.canceled:
|
||||||
|
return False
|
||||||
|
|
||||||
|
progress_callback(1, progress_step, progress_total)
|
||||||
|
progress_step += 1
|
||||||
|
|
||||||
|
p = phonemes.pop(0)
|
||||||
|
if p in g2p.phonemes:
|
||||||
|
phoneme_segment.append(p)
|
||||||
|
if p not in g2p.phonemes or len(phonemes) == 0:
|
||||||
|
output = self.append_best_morshu_phoneme_segment(
|
||||||
|
output, phoneme_segment, audio_out_millis, audio_morshu_millis)
|
||||||
|
phoneme_segment = []
|
||||||
|
if p == ' ':
|
||||||
|
output = self.append_audio_segment(
|
||||||
|
output, AudioSegment.silent(self.space_length), -1,
|
||||||
|
audio_out_millis, audio_morshu_millis)
|
||||||
|
elif p in self.stop_chars:
|
||||||
|
output = self.append_audio_segment(
|
||||||
|
output, AudioSegment.silent(self.stop_length), -1,
|
||||||
|
audio_out_millis, audio_morshu_millis)
|
||||||
|
|
||||||
|
if len(output) == 0:
|
||||||
|
warnings.warn('returned audio segment is empty', UserWarning)
|
||||||
|
self.audio_segment_timings = np.rec.array((0, 0), names=('output', 'morshu'))
|
||||||
|
else:
|
||||||
|
self.audio_segment_timings = np.rec.array(
|
||||||
|
tuple(zip(audio_out_millis, audio_morshu_millis)),
|
||||||
|
names=('output', 'morshu'))
|
||||||
|
|
||||||
|
progress_callback(1, progress_total, progress_total)
|
||||||
|
self.out_audio = output
|
||||||
|
return output
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def substitute_similar_phonemes(phonemes: List[str]):
|
||||||
|
i = 0
|
||||||
|
while i < len(phonemes):
|
||||||
|
if phonemes[i].endswith('0') or phonemes[i].endswith('1') or phonemes[i].endswith('2'):
|
||||||
|
phonemes[i] = phonemes[i][:len(phonemes[i]) - 1]
|
||||||
|
if phonemes[i] in similar_phonemes:
|
||||||
|
phonemes = phonemes[0:i] + similar_phonemes[phonemes[i]] + phonemes[i + 1:]
|
||||||
|
i += 1
|
||||||
|
return phonemes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def append_audio_segment(audio_out: AudioSegment, audio_segment: AudioSegment,
|
||||||
|
morshu_millis_start: int,
|
||||||
|
audio_out_millis: List[int],
|
||||||
|
audio_morshu_millis: List[int]) -> AudioSegment:
|
||||||
|
audio_out_millis.append(len(audio_out))
|
||||||
|
audio_morshu_millis.append(morshu_millis_start)
|
||||||
|
audio_out += audio_segment
|
||||||
|
return audio_out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_phoneme_sequence_occurrences(phonemes: List[str]) -> List[Tuple[int, int]]:
|
||||||
|
occurrences = []
|
||||||
|
for i in range(len(morshu_rec) - len(phonemes)):
|
||||||
|
if (morshu_rec['phoneme'][i:i + len(phonemes)] == phonemes).all():
|
||||||
|
start = morshu_rec['timing'][i - 1]
|
||||||
|
end = morshu_rec['timing'][i + len(phonemes) - 1]
|
||||||
|
occurrences.append((start, end))
|
||||||
|
return occurrences
|
||||||
|
|
||||||
|
def get_best_morshu_single_phoneme(self, phoneme: str, preceding: str = "",
|
||||||
|
succeeding: str = "") -> Tuple[AudioSegment, int]:
|
||||||
|
best_indices = []
|
||||||
|
phoneme_indices = np.where(morshu_rec['phoneme'] == phoneme)[0]
|
||||||
|
if len(phoneme_indices) == 0:
|
||||||
|
return AudioSegment.empty(), 0
|
||||||
|
|
||||||
|
highest_priority = 0
|
||||||
|
for i in phoneme_indices:
|
||||||
|
morshu_preceding = morshu_rec['phoneme'][i - 1]
|
||||||
|
priority = morshu_rec['priority'][i] if self.use_phoneme_priority else 0
|
||||||
|
|
||||||
|
if morshu_preceding == preceding:
|
||||||
|
priority += 10
|
||||||
|
elif any(c in morshu_preceding for c in "AEIOU") and any(c in preceding for c in "AEIOU"):
|
||||||
|
priority += 5
|
||||||
|
|
||||||
|
morshu_succeeding = morshu_rec['phoneme'][i + 1]
|
||||||
|
if morshu_succeeding == succeeding:
|
||||||
|
priority += 10
|
||||||
|
elif any(c in morshu_succeeding for c in "AEIOU") and any(c in succeeding for c in "AEIOU"):
|
||||||
|
priority += 1
|
||||||
|
|
||||||
|
if priority < highest_priority:
|
||||||
|
continue
|
||||||
|
if priority > highest_priority:
|
||||||
|
highest_priority = priority
|
||||||
|
best_indices = []
|
||||||
|
best_indices.append(i)
|
||||||
|
|
||||||
|
index = random.choice(best_indices)
|
||||||
|
segment = morshu_wav[morshu_rec['timing'][index - 1]: morshu_rec['timing'][index]]
|
||||||
|
return segment, morshu_rec['timing'][index - 1]
|
||||||
|
|
||||||
|
def append_best_morshu_phoneme_segment(self, output: AudioSegment, phonemes: List[str],
|
||||||
|
audio_out_millis: List[int] = None,
|
||||||
|
audio_morshu_millis: List[int] = None) -> AudioSegment:
|
||||||
|
phonemes = Morshu.substitute_similar_phonemes(phonemes)
|
||||||
|
if len(phonemes) == 1:
|
||||||
|
segment, start = self.get_best_morshu_single_phoneme(phonemes[0])
|
||||||
|
return Morshu.append_audio_segment(output, segment, start, audio_out_millis, audio_morshu_millis)
|
||||||
|
|
||||||
|
preceding = ""
|
||||||
|
while len(phonemes) > 0:
|
||||||
|
sequence_length = 1
|
||||||
|
segment = AudioSegment.empty()
|
||||||
|
start = 0
|
||||||
|
|
||||||
|
while sequence_length <= len(phonemes):
|
||||||
|
occurrences = Morshu.get_phoneme_sequence_occurrences(phonemes[:sequence_length])
|
||||||
|
if len(occurrences) == 0:
|
||||||
|
break
|
||||||
|
start, end = random.choice(occurrences)
|
||||||
|
segment = morshu_wav[start:end]
|
||||||
|
sequence_length += 1
|
||||||
|
sequence_length -= 1
|
||||||
|
|
||||||
|
if sequence_length == 1:
|
||||||
|
succeeding = phonemes[sequence_length + 1] if sequence_length + 1 < len(phonemes) else ""
|
||||||
|
segment, start = self.get_best_morshu_single_phoneme(phonemes[0], preceding, succeeding)
|
||||||
|
|
||||||
|
output = Morshu.append_audio_segment(output, segment, start, audio_out_millis, audio_morshu_millis)
|
||||||
|
preceding = phonemes[sequence_length - 1]
|
||||||
|
del phonemes[:sequence_length]
|
||||||
|
|
||||||
|
return output
|
||||||
Binary file not shown.
@@ -0,0 +1,4 @@
|
|||||||
|
numpy>=1.24
|
||||||
|
pydub>=0.25
|
||||||
|
nltk>=3.8
|
||||||
|
g2p_en>=2.1
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Morshu TTS Server — standalone HTTP API for MorshuTalk engine.
|
||||||
|
Runs on port 33002. GET /say?text=... returns WAV audio.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
# Engine lives alongside this server (self-contained)
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
|
sys.path.insert(0, str(SCRIPT_DIR))
|
||||||
|
|
||||||
|
from morshutalk_engine import Morshu
|
||||||
|
|
||||||
|
morshu = Morshu()
|
||||||
|
PORT = int(os.environ.get("PORT", 33002))
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
if parsed.path == "/say":
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
text = params.get("text", [""])[0]
|
||||||
|
if not text:
|
||||||
|
self.send_error(400, "Missing 'text' parameter")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
audio = morshu.load_text(text)
|
||||||
|
if audio is False or len(audio) == 0:
|
||||||
|
self.send_error(500, "Failed to generate audio")
|
||||||
|
return
|
||||||
|
buf = io.BytesIO()
|
||||||
|
audio.export(buf, format="wav")
|
||||||
|
wav_bytes = buf.getvalue()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "audio/wav")
|
||||||
|
self.send_header("Content-Length", str(len(wav_bytes)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(wav_bytes)
|
||||||
|
except Exception as e:
|
||||||
|
self.send_error(500, str(e))
|
||||||
|
elif parsed.path == "/health":
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/plain")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"ok")
|
||||||
|
else:
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
print(f"[Morshu] {args[0]}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f"[Morshu] Starting server on port {PORT}...")
|
||||||
|
server = HTTPServer(("0.0.0.0", PORT), Handler)
|
||||||
|
print(f"[Morshu] Ready — http://127.0.0.1:{PORT}/say?text=hello")
|
||||||
|
server.serve_forever()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Start script for the Morshu TTS server.
|
||||||
|
# Creates a local venv on first run, installs deps, then runs the server.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
if [ ! -d venv ]; then
|
||||||
|
echo "Creating virtual environment..."
|
||||||
|
python3 -m venv venv
|
||||||
|
./venv/bin/pip install --upgrade pip
|
||||||
|
./venv/bin/pip install -r requirements.txt
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PORT="${PORT:-33002}"
|
||||||
|
exec ./venv/bin/python "$SCRIPT_DIR/server.py"
|
||||||
Reference in New Issue
Block a user