Skip to content

Commit 6720806

Browse files
csenetclaude
andcommitted
Fix TTS reliability issues during long-term operation
- Implement proper OpenJTalk subprocess cleanup with timeout handling - Re-enable automatic audio file deletion after playback - Add voice client health checks and automatic reconnection - Improve error handling for TTS generation failures - Add process cleanup handlers for graceful shutdown 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 622a259 commit 6720806

File tree

2 files changed

+154
-33
lines changed

2 files changed

+154
-33
lines changed

.github/workflows/image-build.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ jobs:
1414

1515
steps:
1616
- uses: actions/checkout@v3
17-
- uses: docker/setup-qemu-action@v3
1817
- uses: docker/setup-buildx-action@v2
1918
- name: set tag
2019
run: |-
@@ -32,7 +31,7 @@ jobs:
3231
with:
3332
context: ./app
3433
file: ./app/Dockerfile
35-
platforms: linux/amd64,linux/arm64
34+
platforms: linux/amd64
3635
push: true
3736
tags: ghcr.io/102ch/discord-tts-bot:${{ env.IMAGE_TAG }},ghcr.io/102ch/discord-tts-bot:latest
3837
cache-from: type=gha

app/app.py

Lines changed: 153 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,18 @@
88
from threading import Timer
99
from collections import defaultdict, deque
1010
import asyncio
11+
import uuid
12+
import atexit
13+
import signal
14+
import threading
1115

1216
# from dotenv import load_dotenv
1317
# load_dotenv()
1418

1519
queue_dict = defaultdict(deque)
1620
connecting_channels = set()
21+
active_processes = set()
22+
cleanup_lock = threading.Lock()
1723

1824
dictID = int(os.environ['DICT_CH_ID'])
1925
dictMsg = None
@@ -36,8 +42,20 @@ def play(voice_client: discord.VoiceClient, queue: deque):
3642
if not queue or voice_client.is_playing():
3743
return
3844
source = queue.popleft()
39-
# os.remove(source[1])
40-
voice_client.play(source[0], after=lambda e: play(voice_client, queue))
45+
46+
def after_play(error):
47+
if error:
48+
print(f"Player error: {error}")
49+
# Clean up the audio file after playing
50+
try:
51+
if os.path.exists(source[1]):
52+
os.remove(source[1])
53+
except Exception as e:
54+
print(f"Failed to remove audio file {source[1]}: {e}")
55+
# Continue playing next item in queue
56+
play(voice_client, queue)
57+
58+
voice_client.play(source[0], after=after_play)
4159

4260

4361
def current_milli_time() -> int:
@@ -113,25 +131,79 @@ async def jtalk(t) -> str:
113131
htsvoice = ['-m', '/usr/share/hts-voice/mei/mei_normal.htsvoice']
114132
pitch = ['-fm', '-5']
115133
speed = ['-r', '1.0']
116-
# file = str(current_milli_time())
117-
outwav = ['-ow', 'output.wav']
134+
135+
# Generate unique filename to avoid conflicts
136+
filename = f'output_{uuid.uuid4().hex[:8]}.wav'
137+
outwav = ['-ow', filename]
118138
cmd = open_jtalk + mech + htsvoice + pitch + speed + outwav
119-
c = subprocess.Popen(cmd, stdin=subprocess.PIPE)
120-
c.stdin.write(t.encode())
121-
c.stdin.close()
122-
c.wait()
123-
return 'output.wav'
139+
140+
try:
141+
c = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
142+
with cleanup_lock:
143+
active_processes.add(c)
144+
145+
stdout, stderr = c.communicate(input=t.encode(), timeout=30)
146+
147+
if c.returncode != 0:
148+
raise Exception(f"OpenJTalk failed: {stderr.decode()}")
149+
150+
return filename
151+
except subprocess.TimeoutExpired:
152+
c.kill()
153+
raise Exception("OpenJTalk timeout")
154+
except Exception as e:
155+
if os.path.exists(filename):
156+
os.remove(filename)
157+
raise e
158+
finally:
159+
with cleanup_lock:
160+
active_processes.discard(c)
124161

125162

126163
def get_voice_client(channel_id: int) -> discord.VoiceClient | None:
127164
for client in bot.voice_clients:
128-
if client.channel.id == channel_id:
165+
if client.channel and client.channel.id == channel_id:
129166
return client
130167
else:
131168
return None
132169

133170

134-
async def text_check(text: str, user_name: str) -> str:
171+
async def check_voice_client_health(voice_client: discord.VoiceClient) -> bool:
172+
"""Check if voice client is healthy and can play audio"""
173+
try:
174+
if not voice_client or not voice_client.is_connected():
175+
return False
176+
# Test if the voice client is responsive
177+
return True
178+
except Exception as e:
179+
print(f"Voice client health check failed: {e}")
180+
return False
181+
182+
183+
async def ensure_voice_connection(guild: discord.Guild, channel_id: int) -> discord.VoiceClient | None:
184+
"""Ensure we have a healthy voice connection"""
185+
voice_client = get_voice_client(channel_id)
186+
187+
if voice_client and await check_voice_client_health(voice_client):
188+
return voice_client
189+
190+
# Reconnect if connection is unhealthy
191+
if voice_client:
192+
try:
193+
await voice_client.disconnect()
194+
except:
195+
pass
196+
197+
try:
198+
channel = bot.get_channel(channel_id)
199+
if channel:
200+
return await channel.connect()
201+
except Exception as e:
202+
print(f"Failed to reconnect to voice channel: {e}")
203+
return None
204+
205+
206+
async def text_check(text: str, user_name: str) -> tuple[str, str]:
135207
print(text)
136208
if len(text) > 150:
137209
raise Exception("文字数が長すぎるよ")
@@ -145,10 +217,17 @@ async def text_check(text: str, user_name: str) -> str:
145217
text = user_name + text
146218
if len(text) > 150:
147219
raise Exception("文字数が長すぎるよ")
148-
filename = await jtalk(text)
149-
if os.path.getsize(filename) > 10000000:
150-
raise Exception("再生時間が長すぎるよ")
151-
return text, filename
220+
221+
try:
222+
filename = await jtalk(text)
223+
if os.path.getsize(filename) > 10000000:
224+
if os.path.exists(filename):
225+
os.remove(filename)
226+
raise Exception("再生時間が長すぎるよ")
227+
return text, filename
228+
except Exception as e:
229+
print(f"TTS generation failed: {e}")
230+
raise e
152231

153232

154233
client_id = os.environ['DISCORD_CLIENT_ID']
@@ -329,16 +408,25 @@ async def on_message(message: discord.Message):
329408
try:
330409
text, filename = await text_check(text, user_name)
331410
except Exception as e:
332-
return await message.channel.send(e)
411+
print(f"Text processing error: {e}")
412+
return await message.channel.send(f"読み上げエラー: {e}")
333413

334-
if not message.guild.voice_client:
414+
# Ensure voice connection is healthy
415+
voice_client = await ensure_voice_connection(message.guild, message.channel.id)
416+
if not voice_client:
417+
print("Failed to establish voice connection")
335418
return await bot.process_commands(message)
336419

337-
enqueue(message.guild.voice_client, message.guild,
338-
discord.FFmpegPCMAudio(filename), filename)
339-
# timer = Timer(3, os.remove, (filename, ))
340-
# timer.start()
341-
# os.remove(filename)
420+
try:
421+
enqueue(voice_client, message.guild,
422+
discord.FFmpegPCMAudio(filename), filename)
423+
except Exception as e:
424+
print(f"Audio enqueue error: {e}")
425+
# Clean up file if enqueue fails
426+
if os.path.exists(filename):
427+
os.remove(filename)
428+
return await message.channel.send("音声の再生に失敗しました")
429+
342430
# コマンド側へメッセージ内容を渡す
343431
await bot.process_commands(message)
344432

@@ -353,13 +441,19 @@ async def on_voice_state_update(member: discord.Member, before:discord.VoiceStat
353441
else:
354442
username=member.display_name
355443
if not before.channel and after.channel:
356-
filename = await jtalk(username +"さんこんにちは!")
357-
enqueue(member.guild.voice_client, member.guild,
358-
discord.FFmpegPCMAudio(filename), filename)
444+
try:
445+
filename = await jtalk(username +"さんこんにちは!")
446+
enqueue(member.guild.voice_client, member.guild,
447+
discord.FFmpegPCMAudio(filename), filename)
448+
except Exception as e:
449+
print(f"Failed to play greeting: {e}")
359450
if before.channel and not after.channel:
360-
filename = await jtalk(username + "さんが退出しました")
361-
enqueue(member.guild.voice_client, member.guild,
362-
discord.FFmpegPCMAudio(filename), filename)
451+
try:
452+
filename = await jtalk(username + "さんが退出しました")
453+
enqueue(member.guild.voice_client, member.guild,
454+
discord.FFmpegPCMAudio(filename), filename)
455+
except Exception as e:
456+
print(f"Failed to play farewell: {e}")
363457
allbot = True
364458
selfcheck = False
365459
for mem in before.channel.members:
@@ -376,10 +470,38 @@ async def on_voice_state_update(member: discord.Member, before:discord.VoiceStat
376470
await client.disconnect()
377471
await before.channel.send('ボイスチャンネルからログアウトしました')
378472

473+
def cleanup_processes():
474+
"""Clean up any remaining OpenJTalk processes on exit"""
475+
with cleanup_lock:
476+
for process in list(active_processes):
477+
try:
478+
if process.poll() is None:
479+
process.terminate()
480+
process.wait(timeout=5)
481+
except:
482+
try:
483+
process.kill()
484+
except:
485+
pass
486+
active_processes.clear()
487+
488+
489+
# Register cleanup handlers
490+
atexit.register(cleanup_processes)
491+
signal.signal(signal.SIGTERM, lambda signum, frame: cleanup_processes())
492+
signal.signal(signal.SIGINT, lambda signum, frame: cleanup_processes())
493+
494+
379495
async def main():
380496
# start the client
381-
async with bot:
497+
try:
498+
async with bot:
499+
await bot.start(client_id)
500+
except Exception as e:
501+
print(f"Bot startup failed: {e}")
502+
finally:
503+
cleanup_processes()
382504

383-
await bot.start(client_id)
384505

385-
asyncio.run(main())
506+
if __name__ == "__main__":
507+
asyncio.run(main())

0 commit comments

Comments
 (0)