From 5648fa77dc0e1cd34a2f0b54fa01f1a5fb6b3ee3 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Sat, 18 Oct 2025 02:22:08 -0500 Subject: [PATCH 01/19] Added dynamic voice synthesis --- bot.py | 80 ++++++++++++++++++++++++++++++++++++++++++----- generator.py | 49 +++++++++++++++++++---------- requirements.txt | Bin 426 -> 402 bytes 3 files changed, 106 insertions(+), 23 deletions(-) diff --git a/bot.py b/bot.py index fac39a6..4e66541 100644 --- a/bot.py +++ b/bot.py @@ -3,16 +3,35 @@ import discord from discord.ext import commands -description = "A bot to generate alternate names for Benedict Cumberbatch." +BOT_DESCRIPTION = "A bot to generate alternate names for Benedict Cumberbatch." +RAW_PREFIX = "!batch" +SPACE_PREFIX = RAW_PREFIX + " " +BOT_TOKEN = os.getenv("BOT_TOKEN") +FFMPEG_EXEC = "C:\\Users\\ebowe\\AppData\\Local\\Microsoft\\WinGet\\Packages\\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\\ffmpeg-8.0-full_build\\bin\\ffmpeg.exe" intents = discord.Intents.default() intents.message_content = True intents.messages = True -bot = commands.Bot(command_prefix='!', description=description, intents=intents) +bot = commands.Bot(command_prefix=SPACE_PREFIX, description=BOT_DESCRIPTION, intents=intents) name_api = Generator() -BOT_TOKEN = os.getenv("BOT_TOKEN") +g_last_name = "Benedict Cumberbatch" +g_last_phone = "benedict cumberbatch" + + +def vc_for(guild: discord.Guild) -> discord.VoiceClient | None: + return discord.utils.get(bot.voice_clients, guild=guild) + + +async def _gen(ctx: commands.Context): + global g_last_name, g_last_phone + name, phone = name_api.name() + g_last_name = name + g_last_phone = phone + await ctx.send(name) + + @bot.event async def on_ready(): @@ -20,10 +39,57 @@ async def on_ready(): print('------') -@bot.command(name='batch') -async def batch(ctx): - name = name_api.name() - await ctx.send(name) +@bot.event +async def on_message(message: discord.Message): + if message.author.bot: + return + + if message.content.strip() == RAW_PREFIX: + ctx = await bot.get_context(message) + return await _gen(ctx) + else: + return await bot.process_commands(message) + + +@bot.command(name="gen") +async def gen(ctx): + await _gen(ctx) + +@bot.command(name="join") +async def join(ctx: commands.Context): + if not isinstance(ctx.author, discord.Member): + return await ctx.reply("Could not resolve your member information.", mention_author=False) + if not ctx.author.voice or not ctx.author.voice.channel: + return await ctx.reply("You are not connected to a voice channel.", mention_author=False) + channel = ctx.author.voice.channel + vc = vc_for(ctx.guild) + if vc and vc.is_connected(): + await vc.move_to(channel) + else: + await channel.connect() + return await ctx.reply(f"Joined {channel.name}.", mention_author=False) + + +@bot.command(name="leave") +async def leave(ctx: commands.Context): + vc = vc_for(ctx.guild) + if not vc or not vc.is_connected(): + return await ctx.reply("I am not connected to a voice channel.", mention_author=False) + await vc.disconnect() + return await ctx.reply("Disconnected.", mention_author=False) + + +@bot.command(name="play") +async def play(ctx: commands.Context): + vc = vc_for(ctx.guild) + if not vc or not vc.is_connected(): + return await ctx.reply("I am not connected to a voice channel.", mention_author=False) + if not vc.is_playing(): + name_api.vocalize(g_last_phone) + vc.play(discord.FFmpegPCMAudio(executable=FFMPEG_EXEC, source="audio/output.wav")) + return await ctx.reply("Playing audio.", mention_author=False) + else: + return await ctx.reply("Audio is already playing.", mention_author=False) def run(token=BOT_TOKEN): diff --git a/generator.py b/generator.py index e453771..b6a65a5 100644 --- a/generator.py +++ b/generator.py @@ -2,32 +2,49 @@ import json from pathlib import Path from typing import Union +from piper import PiperVoice +import wave -PATH_TO_JSON = Path(__file__).parent / "words.json" +PATH_TO_JSON = Path(__file__).parent / "phonemized_words.json" +VOICE = PiperVoice.load("C:\\Users\\ebowe\\Piper TTS\\en_GB-alan-medium.onnx") class Generator: - def __init__(self, json_path: Union[Path, str]=PATH_TO_JSON): + def __init__(self, json_path: Union[Path, str]=PATH_TO_JSON): + with open(PATH_TO_JSON, 'r') as f: + word_list = json.load(f) - with open(PATH_TO_JSON, 'r') as f: - word_list = json.load(f) - - self.givenPart1_list = word_list.get("givenPart1", ["Bene"]) - self.givenPart2_list = word_list.get("givenPart2", ["dict"]) - self.surnamePart1_list = word_list.get("surnamePart1", ["Cumber"]) - self.surnamePart2_list = word_list.get("surnamePart2", ["batch"]) - - return + self.givenPart1_map = word_list.get("givenPart1", {"Bene": "bene"}) + self.givenPart2_map = word_list.get("givenPart2", {"dict": "dict"}) + self.surnamePart1_map = word_list.get("surnamePart1", {"Cumber": "cumber"}) + self.surnamePart2_map = word_list.get("surnamePart2", {"batch": "batch"}) + return - def name(self): - first = random.choice(self.givenPart1_list) + random.choice(self.givenPart2_list) - last = random.choice(self.surnamePart1_list) + random.choice(self.surnamePart2_list) - return first.capitalize() + " " + last.capitalize() + def name(self): + first_part_1 = random.choice(list(self.givenPart1_map.keys())) + first_part_2 = random.choice(list(self.givenPart2_map.keys())) + last_part_1 = random.choice(list(self.surnamePart1_map.keys())) + last_part_2 = random.choice(list(self.surnamePart2_map.keys())) + first_phone_part_1 = self.givenPart1_map[first_part_1] + first_phone_part_2 = self.givenPart2_map[first_part_2] + last_phone_part_1 = self.surnamePart1_map[last_part_1] + last_phone_part_2 = self.surnamePart2_map[last_part_2] + first = first_part_1 + first_part_2 + last = last_part_1 + last_part_2 + phone = first_phone_part_1 + first_phone_part_2 + " " + last_phone_part_1 + last_phone_part_2 + return first.capitalize() + " " + last.capitalize(), phone + + @staticmethod + def vocalize(phone): + phone = f"[[ {phone} ]]" + with wave.open("audio/output.wav", 'wb') as output: + VOICE.synthesize_wav(phone, output) + if __name__ == "__main__": gen = Generator() p = Path('~', 'Piper TTS', 'names.txt').expanduser() with open(p, 'w') as f: for _ in range(100): - f.write(gen.name() + '.\n') \ No newline at end of file + f.write(gen.name()[0] + '.\n') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d40d2a6f436d6057af70cf6fff946de1dd3f68a5..bda94a98ac0d689761b9e1ec45a8c3f27cff5950 100644 GIT binary patch delta 71 zcmZ3*Jc)UN!9*jaiD60;t7In5P@8x_ZsG$uRx<`Y29wFGjH;qs44Dim40#NR47NaM Z3=}tHFqrJjs607?QE74yqZAVt0|3%o5fuOc delta 168 zcmbQlyoz~(0VD53ePtjKs4UORz{QZrPy&QS48;t#Kxo8Z%Am(!48#Tuyc7G?f$Fx( ztAo^~Fk}MNBs1g#)usUD3m7V)I`qJLjDYIDt4e~DW&#!D0SyKj2r|kLWY}awMxd>e cgBg_-Kx!(1@ Date: Tue, 21 Oct 2025 17:02:22 -0500 Subject: [PATCH 02/19] Changed commands, updated dockerfile, added env variables --- bot.py | 128 +++++++++++++++++++++++++++++++++++++++-------- dockerfile | 24 ++++++--- generator.py | 17 +++++-- requirements.txt | Bin 402 -> 498 bytes 4 files changed, 138 insertions(+), 31 deletions(-) diff --git a/bot.py b/bot.py index 4e66541..231791d 100644 --- a/bot.py +++ b/bot.py @@ -2,34 +2,82 @@ import os import discord from discord.ext import commands +from typing import Any, Optional +import logging +from pathlib import Path + +DEBUG = os.getenv("DEBUG") +AUDIO_DIR = os.getenv("AUDIO_DIR", "/audio") +FFMPEG_EXEC = os.getenv("FFMPEG_EXEC", "ffmpeg") -BOT_DESCRIPTION = "A bot to generate alternate names for Benedict Cumberbatch." RAW_PREFIX = "!batch" +RAW_PREFIX_SHORT = "!b" SPACE_PREFIX = RAW_PREFIX + " " BOT_TOKEN = os.getenv("BOT_TOKEN") -FFMPEG_EXEC = "C:\\Users\\ebowe\\AppData\\Local\\Microsoft\\WinGet\\Packages\\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\\ffmpeg-8.0-full_build\\bin\\ffmpeg.exe" +BOT_DESCRIPTION = ("A bot to generate alternate names for Benedict Cumberbatch.\n" + "\n" + f"Usage: {RAW_PREFIX_SHORT} [command] [arguments]\n" + f"\n" + f"Running with no command is equivalent to running '{RAW_PREFIX_SHORT} gen'\n") + +AUTOSPEAK_HELP = "If autospeak is on, the bot automatically speaks any generated names" intents = discord.Intents.default() intents.message_content = True intents.messages = True -bot = commands.Bot(command_prefix=SPACE_PREFIX, description=BOT_DESCRIPTION, intents=intents) + +class CustomHelp(commands.DefaultHelpCommand): + def get_command_signature(self, command): + return f"Usage: {super().get_command_signature(command)}" + def get_ending_note(self): + return f"Type {RAW_PREFIX_SHORT} [help|?] for more info on a command." + def add_indented_commands(self, _commands, *, heading, max_size=None): + max_size = super().get_max_size(_commands) + self.indent + return super().add_indented_commands(_commands, heading=heading, max_size=max_size) + +defaultHelpCommand = CustomHelp( + show_parameter_descriptions=False, + no_category="Commands", + command_attrs={ + "aliases": ["?"], + "help": "Get general help information or help about a specific command" + } +) +bot = commands.Bot(command_prefix=commands.when_mentioned_or(SPACE_PREFIX, "!b "), description=BOT_DESCRIPTION, + intents=intents, + help_command=defaultHelpCommand) name_api = Generator() g_last_name = "Benedict Cumberbatch" g_last_phone = "benedict cumberbatch" +g_autospeak = False def vc_for(guild: discord.Guild) -> discord.VoiceClient | None: return discord.utils.get(bot.voice_clients, guild=guild) +async def _speak(ctx: commands.Context) -> Optional[Any]: + global g_autospeak + vc = vc_for(ctx.guild) + if not vc or not vc.is_connected(): + return await ctx.reply("I am not connected to a voice channel.", mention_author=False) + if not vc.is_playing(): + name_api.vocalize(g_last_phone) + audio_source = Path(AUDIO_DIR) / "output.wav" + return vc.play(discord.FFmpegPCMAudio(executable=FFMPEG_EXEC, source=str(audio_source))) + else: + return await ctx.reply("Audio is already playing.", mention_author=False) + async def _gen(ctx: commands.Context): - global g_last_name, g_last_phone + global g_last_name, g_last_phone, g_autospeak name, phone = name_api.name() g_last_name = name g_last_phone = phone - await ctx.send(name) + await ctx.reply(name) + if g_autospeak: + await _speak(ctx) @@ -37,25 +85,48 @@ async def _gen(ctx: commands.Context): async def on_ready(): print(f'Logged in as {bot.user.name} - {bot.user.id}') print('------') + await bot.change_presence( + status=discord.Status.online, + activity=discord.Activity( + type=discord.ActivityType.custom, + state=f"Type '{RAW_PREFIX_SHORT} help'", + name=bot.user.name + ) + ) @bot.event async def on_message(message: discord.Message): if message.author.bot: return - - if message.content.strip() == RAW_PREFIX: + if message.content.strip() == RAW_PREFIX or message.content.strip() == RAW_PREFIX_SHORT: ctx = await bot.get_context(message) return await _gen(ctx) else: return await bot.process_commands(message) +@bot.event +async def on_command(ctx: commands.Context): + print(f"Command '{ctx.command}' invoked by {ctx.author} in guild '{ctx.guild}' (ID: {ctx.guild.id})") + + +@bot.event +async def on_command_error(ctx: commands.Context, error: commands.CommandError): + if isinstance(error, commands.CommandNotFound): + args = ctx.message.content.split() + cmd = args[1] if len(args) > 1 else "" + return await ctx.reply(f"Unknown command: '{cmd}'\nUse '{RAW_PREFIX} help'") + return await ctx.reply(f"An error occurred: {str(error)}", mention_author=False) + -@bot.command(name="gen") +@bot.command(name="gen", + help="Generate a new name. (Hint: You can also just type '!b')") async def gen(ctx): await _gen(ctx) -@bot.command(name="join") + +@bot.command(name="join", + help="Join the voice channel you are in") async def join(ctx: commands.Context): if not isinstance(ctx.author, discord.Member): return await ctx.reply("Could not resolve your member information.", mention_author=False) @@ -70,30 +141,45 @@ async def join(ctx: commands.Context): return await ctx.reply(f"Joined {channel.name}.", mention_author=False) -@bot.command(name="leave") +@bot.command(name="leave", + help="Leave the voice channel you are in") async def leave(ctx: commands.Context): + global g_autospeak vc = vc_for(ctx.guild) if not vc or not vc.is_connected(): return await ctx.reply("I am not connected to a voice channel.", mention_author=False) await vc.disconnect() + g_autospeak = False return await ctx.reply("Disconnected.", mention_author=False) -@bot.command(name="play") -async def play(ctx: commands.Context): - vc = vc_for(ctx.guild) - if not vc or not vc.is_connected(): - return await ctx.reply("I am not connected to a voice channel.", mention_author=False) - if not vc.is_playing(): - name_api.vocalize(g_last_phone) - vc.play(discord.FFmpegPCMAudio(executable=FFMPEG_EXEC, source="audio/output.wav")) - return await ctx.reply("Playing audio.", mention_author=False) +@bot.command(name="speak", + brief="Speak name in voice channel", + help="Speak the last generated name in the voice channel.", + aliases=["say"]) +async def speak(ctx: commands.Context): + await _speak(ctx) + + +@bot.command(name="autospeak", + brief="Turn on/off autospeak", + usage="[on|off]", + help="Running without arguments turns on autospeak", + description="If autospeak is on, the bot automatically speaks any generated names", + aliases=["auto"]) +async def autospeak(ctx: commands.Context, subcmd: str = "on"): + global g_autospeak + if subcmd == "on": + g_autospeak = True + return await ctx.reply(f"Autospeak on") else: - return await ctx.reply("Audio is already playing.", mention_author=False) + g_autospeak = False + return await ctx.reply(f"Autospeak off") def run(token=BOT_TOKEN): - print(f"Using token: {token}") + if DEBUG: + print(f"Using token: {token}") if token is None: raise ValueError("The bot token is None. Please either set the BOT_TOKEN environment variable or pass a token " "directly.") diff --git a/dockerfile b/dockerfile index 2508af8..c1de36b 100644 --- a/dockerfile +++ b/dockerfile @@ -1,19 +1,31 @@ # Dockerfile - FROM python:3.13-slim -# Create a non-root user -RUN useradd -m botuser +ENV PIPER_VOICES_DIR=/voices +ENV APP_ROOT=/app +ENV LOG_DIR=/log +ENV AUDIO_DIR=/audio + +ENV PYTHONUNBUFFERED=1 + +# System dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg libopus0 libsodium23 ca-certificates curl espeak \ + && rm -rf /var/lib/apt/lists/* # Install any needed packages -WORKDIR /app +WORKDIR ${APP_ROOT} # Copy requirements first for caching -COPY requirements.txt . +COPY requirements.txt ${APP_ROOT}/ RUN pip install --no-cache-dir -r requirements.txt # Copy your bot source code -COPY . . +COPY . ${APP_ROOT}/ + +# Create a non-root user +RUN useradd -m botuser + # Use the non-root user USER botuser diff --git a/generator.py b/generator.py index b6a65a5..a7890a9 100644 --- a/generator.py +++ b/generator.py @@ -4,9 +4,14 @@ from typing import Union from piper import PiperVoice import wave +import os PATH_TO_JSON = Path(__file__).parent / "phonemized_words.json" -VOICE = PiperVoice.load("C:\\Users\\ebowe\\Piper TTS\\en_GB-alan-medium.onnx") +VOICE_NAME = os.getenv("PIPER_VOICE", "en_GB-alan-medium") +VOICES_DIR = os.getenv("PIPER_VOICES_DIR", "/voices") +VOICE_FILE = Path(VOICES_DIR) / f"{VOICE_NAME}.onnx" +VOICE = PiperVoice.load(VOICE_FILE) +AUDIO_DIR = os.getenv("AUDIO_DIR", "/audio") class Generator: @@ -38,13 +43,17 @@ def name(self): @staticmethod def vocalize(phone): phone = f"[[ {phone} ]]" - with wave.open("audio/output.wav", 'wb') as output: + wav_file = Path(AUDIO_DIR) / f"output.wav" + with wave.open(str(wav_file), 'wb') as output: VOICE.synthesize_wav(phone, output) -if __name__ == "__main__": +def main(): gen = Generator() p = Path('~', 'Piper TTS', 'names.txt').expanduser() with open(p, 'w') as f: for _ in range(100): - f.write(gen.name()[0] + '.\n') \ No newline at end of file + f.write(gen.name()[0] + '.\n') + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index bda94a98ac0d689761b9e1ec45a8c3f27cff5950..cdc18e9285cfe0bf63732f4fa2332df4be611aa0 100644 GIT binary patch delta 104 zcmbQl{E2zPB*ugShD?S6hE#?k23>{{AS`CE1wumxJqBYSHelca%4RU+Gvoo)h($ delta 7 OcmeywJc)V3Bt`%X(gMx^ From ef3657bff4057b06ab72c71a96f5135f1e6b8f46 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Wed, 22 Oct 2025 11:17:23 -0500 Subject: [PATCH 03/19] Removed line --- dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/dockerfile b/dockerfile index c1de36b..e6ebf47 100644 --- a/dockerfile +++ b/dockerfile @@ -5,7 +5,6 @@ ENV PIPER_VOICES_DIR=/voices ENV APP_ROOT=/app ENV LOG_DIR=/log ENV AUDIO_DIR=/audio - ENV PYTHONUNBUFFERED=1 # System dependencies From a77c04a308ef3bafad037f6dc18997e39d052fb3 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Wed, 22 Oct 2025 11:39:56 -0500 Subject: [PATCH 04/19] Update github workflow to add version and beta tags --- .github/workflows/docker.yml | 54 ++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e9d1677..e7a35ea 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -6,35 +6,71 @@ env: on: push: branches: + - beta - main # Trigger build on pushes to main branch + tags: + - 'v*.*.*' # Trigger build on version tags like v1.0.0 + - 'v*.*.*-beta.*' # Trigger build on beta tags like v1.0.0-beta.1 + +permissions: + contents: read + packages: write + +concurrency: + group: build-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: build: runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - name: Checkout code uses: actions/checkout@v4 + - name: Normalize repo owner + id: rpo + run: | + echo "owner_lc=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_OUTPUT + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to GitHub Container Registry + - name: Log in to GitHub Container Registry (GHCR) uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract build metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ steps.repo.outputs.owner_lc }}/${{ env.IMAGE_NAME }} + tags: | + # Stable release tags (v1.2.3 OR v1.2) + type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-beta') }} + type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-beta') }} + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + # Beta prerelease tags (v1.2.3-beta.1 OR v1.2-beta.1) + type=raw,value=${{ github.ref_name#v }},enable=${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '-beta') }} + type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || contains(github.ref, '-beta') }} + + # Unique commit SHA tag + type=sha,format=short + - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . push: true - tags: | - ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:latest - ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + platforms: linux/amd64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=ghcr.io/${{ steps.repo.outputs.owner_lc }}/${{ env.IMAGE_NAME }}:buildcache + cache-to: type=registry,ref=ghcr.io/${{ steps.repo.outputs.owner_lc }}/${{ env.IMAGE_NAME }}:buildcache,mode=max From c4a9b5f9e064e4f9c56a778b318d2948aac1a6a6 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Wed, 22 Oct 2025 11:41:34 -0500 Subject: [PATCH 05/19] Add image version to dockerfile as env var --- dockerfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dockerfile b/dockerfile index e6ebf47..e6a8c44 100644 --- a/dockerfile +++ b/dockerfile @@ -1,12 +1,17 @@ # Dockerfile FROM python:3.13-slim +LABEL maintainer="Eric Bower" + ENV PIPER_VOICES_DIR=/voices ENV APP_ROOT=/app ENV LOG_DIR=/log ENV AUDIO_DIR=/audio ENV PYTHONUNBUFFERED=1 +ARG IMAGE_VERSION +ENV IMAGE_VERSION=${IMAGE_VERSION} + # System dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ ffmpeg libopus0 libsodium23 ca-certificates curl espeak \ From 649c457028add4b498c8b9c5f9170b92714f3cd2 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Wed, 22 Oct 2025 11:42:10 -0500 Subject: [PATCH 06/19] Added image arg to workflow --- .github/workflows/docker.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e7a35ea..b6e00bd 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -74,3 +74,5 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=ghcr.io/${{ steps.repo.outputs.owner_lc }}/${{ env.IMAGE_NAME }}:buildcache cache-to: type=registry,ref=ghcr.io/${{ steps.repo.outputs.owner_lc }}/${{ env.IMAGE_NAME }}:buildcache,mode=max + build-args: | + IMAGE_VERSION=${{ steps.meta.outputs.version }} From 2e57163f43d937f57a2d800ea6750c5b68f9edcb Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Wed, 22 Oct 2025 11:47:03 -0500 Subject: [PATCH 07/19] Fix workflow file --- .github/workflows/docker.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b6e00bd..5b0803a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -33,6 +33,15 @@ jobs: run: | echo "owner_lc=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_OUTPUT + - name: Normalize version tag + id: tag + run: | + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + ver="${GITHUB_REF_NAME}" + ver="${ver#v}" # Remove leading 'v' + echo "tag=${ver}" >> $GITHUB_OUTPUT + fi + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -58,7 +67,7 @@ jobs: type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} # Beta prerelease tags (v1.2.3-beta.1 OR v1.2-beta.1) - type=raw,value=${{ github.ref_name#v }},enable=${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '-beta') }} + type=raw,value=${{ github.ref_name }},enable=${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '-beta') }} type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || contains(github.ref, '-beta') }} # Unique commit SHA tag From 1bd307dae3133a2d2269b31c5248f5ed34e5c3a8 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Wed, 22 Oct 2025 11:50:47 -0500 Subject: [PATCH 08/19] Fix tags in workflow --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5b0803a..703ea51 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -67,7 +67,7 @@ jobs: type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} # Beta prerelease tags (v1.2.3-beta.1 OR v1.2-beta.1) - type=raw,value=${{ github.ref_name }},enable=${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '-beta') }} + type=raw,value=${{ steps.tag.outputs.tag }},enable=${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '-beta') }} type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || contains(github.ref, '-beta') }} # Unique commit SHA tag From 919aa68c8051c9d62d3a6c7f5bcde111a922e0cc Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Wed, 22 Oct 2025 13:05:58 -0500 Subject: [PATCH 09/19] Removed unused var --- bot.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot.py b/bot.py index 231791d..ad3251a 100644 --- a/bot.py +++ b/bot.py @@ -20,8 +20,6 @@ f"\n" f"Running with no command is equivalent to running '{RAW_PREFIX_SHORT} gen'\n") -AUTOSPEAK_HELP = "If autospeak is on, the bot automatically speaks any generated names" - intents = discord.Intents.default() intents.message_content = True intents.messages = True From b4557ea26a03e14a24bd82dc8de27b1ecd8c748a Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Wed, 22 Oct 2025 13:20:36 -0500 Subject: [PATCH 10/19] Add voice install step --- dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dockerfile b/dockerfile index e6a8c44..a741847 100644 --- a/dockerfile +++ b/dockerfile @@ -7,6 +7,7 @@ ENV PIPER_VOICES_DIR=/voices ENV APP_ROOT=/app ENV LOG_DIR=/log ENV AUDIO_DIR=/audio +ENV PIPER_VOICE=en_GB-alan-medium ENV PYTHONUNBUFFERED=1 ARG IMAGE_VERSION @@ -24,6 +25,8 @@ WORKDIR ${APP_ROOT} COPY requirements.txt ${APP_ROOT}/ RUN pip install --no-cache-dir -r requirements.txt +RUN python -m piper.download_voices --download-dir ${PIPER_VOICES_DIR} ${PIPER_VOICE} + # Copy your bot source code COPY . ${APP_ROOT}/ From 67a28e773407efe63ca36e69b6a76d0efd06c7ed Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Wed, 22 Oct 2025 13:41:58 -0500 Subject: [PATCH 11/19] Add debug to piper voice download --- dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dockerfile b/dockerfile index a741847..b82a829 100644 --- a/dockerfile +++ b/dockerfile @@ -25,7 +25,7 @@ WORKDIR ${APP_ROOT} COPY requirements.txt ${APP_ROOT}/ RUN pip install --no-cache-dir -r requirements.txt -RUN python -m piper.download_voices --download-dir ${PIPER_VOICES_DIR} ${PIPER_VOICE} +RUN python -m piper.download_voices --debug --download-dir ${PIPER_VOICES_DIR} ${PIPER_VOICE} # Copy your bot source code COPY . ${APP_ROOT}/ From 66b2df926cfe0e6855d2df052bc5976b58bda3e8 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Wed, 22 Oct 2025 13:47:51 -0500 Subject: [PATCH 12/19] Moved piper voice download line --- dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dockerfile b/dockerfile index b82a829..8c630c0 100644 --- a/dockerfile +++ b/dockerfile @@ -25,7 +25,6 @@ WORKDIR ${APP_ROOT} COPY requirements.txt ${APP_ROOT}/ RUN pip install --no-cache-dir -r requirements.txt -RUN python -m piper.download_voices --debug --download-dir ${PIPER_VOICES_DIR} ${PIPER_VOICE} # Copy your bot source code COPY . ${APP_ROOT}/ @@ -37,5 +36,7 @@ RUN useradd -m botuser # Use the non-root user USER botuser +RUN python -m piper.download_voices --debug --download-dir ${PIPER_VOICES_DIR} ${PIPER_VOICE} + # Run your bot CMD ["python", "bot.py"] From 5de587a51268c1014053040c7e64893ae15997c5 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Wed, 22 Oct 2025 13:52:16 -0500 Subject: [PATCH 13/19] Replace RUN with CMD --- dockerfile | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/dockerfile b/dockerfile index 8c630c0..9bd60e5 100644 --- a/dockerfile +++ b/dockerfile @@ -25,18 +25,17 @@ WORKDIR ${APP_ROOT} COPY requirements.txt ${APP_ROOT}/ RUN pip install --no-cache-dir -r requirements.txt - # Copy your bot source code COPY . ${APP_ROOT}/ # Create a non-root user RUN useradd -m botuser - # Use the non-root user USER botuser - -RUN python -m piper.download_voices --debug --download-dir ${PIPER_VOICES_DIR} ${PIPER_VOICE} + +# Download piper voice +CMD ["python", "-m", "piper.download_voices", "--debug", "--download-dir", "${PIPER_VOICES_DIR}", "${PIPER_VOICE}"] # Run your bot CMD ["python", "bot.py"] From 76e76ef431885f9bbe7d461d6f86bceccbb78dc1 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Wed, 22 Oct 2025 14:06:00 -0500 Subject: [PATCH 14/19] Added entrypoint file --- docker-entrypoint.sh | 23 +++++++++++++++++++++++ dockerfile | 8 +++++--- 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 docker-entrypoint.sh diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..c787283 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${PIPER_VOICES_DIR:=/voices}" +: "${PIPER_VOICE:=en_GB-alan-medium}" + +PIPER_VOICE_FILE="$PIPER_VOICES_DIR/$PIPER_VOICE.onyx" + +echo "[entrypoint] Voices dir: $PIPER_VOICES_DIR" +mkdir -p "$PIPER_VOICES_DIR" + +if [[ ! -f "$PIPER_VOICE_FILE" ]]; then + echo "[entrypoint] Voice not found at $PIPER_VOICE_FILE, downloading..." + python -m piper.download_voices \ + --debug \ + --download-dir "$PIPER_VOICES_DIR" \ + "$PIPER_VOICE" +else + echo "[entrypoint] Voice found at $PIPER_VOICE_FILE" +fi + +echo "[entrypoint] Launching bot..." +exec "$@" \ No newline at end of file diff --git a/dockerfile b/dockerfile index 9bd60e5..8e9d08c 100644 --- a/dockerfile +++ b/dockerfile @@ -28,14 +28,16 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy your bot source code COPY . ${APP_ROOT}/ +# Add entrypoint +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + # Create a non-root user RUN useradd -m botuser # Use the non-root user USER botuser -# Download piper voice -CMD ["python", "-m", "piper.download_voices", "--debug", "--download-dir", "${PIPER_VOICES_DIR}", "${PIPER_VOICE}"] - +ENTRYPOINT ["docker-entrypoint.sh"] # Run your bot CMD ["python", "bot.py"] From 86d5fa002e95ab24e4abf586012489b25cfec15d Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Wed, 22 Oct 2025 14:14:53 -0500 Subject: [PATCH 15/19] Add step to create directories --- dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dockerfile b/dockerfile index 8e9d08c..1ed9927 100644 --- a/dockerfile +++ b/dockerfile @@ -13,6 +13,9 @@ ENV PYTHONUNBUFFERED=1 ARG IMAGE_VERSION ENV IMAGE_VERSION=${IMAGE_VERSION} +# Create necessary directories +RUN mkdir -p ${APP_ROOT} ${LOG_DIR} ${AUDIO_DIR} ${PIPER_VOICES_DIR} + # System dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ ffmpeg libopus0 libsodium23 ca-certificates curl espeak \ From d068e4e7df18f70a205c67d12ff6743e18646bf0 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Wed, 22 Oct 2025 14:21:56 -0500 Subject: [PATCH 16/19] Fixed file permissions --- bot.py | 1 + dockerfile | 2 ++ 2 files changed, 3 insertions(+) diff --git a/bot.py b/bot.py index ad3251a..5d7bbbd 100644 --- a/bot.py +++ b/bot.py @@ -114,6 +114,7 @@ async def on_command_error(ctx: commands.Context, error: commands.CommandError): args = ctx.message.content.split() cmd = args[1] if len(args) > 1 else "" return await ctx.reply(f"Unknown command: '{cmd}'\nUse '{RAW_PREFIX} help'") + print(str(error)) return await ctx.reply(f"An error occurred: {str(error)}", mention_author=False) diff --git a/dockerfile b/dockerfile index 1ed9927..9235560 100644 --- a/dockerfile +++ b/dockerfile @@ -38,6 +38,8 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh # Create a non-root user RUN useradd -m botuser +RUN chown -R botuser:botuser ${APP_ROOT} ${LOG_DIR} ${AUDIO_DIR} ${PIPER_VOICES_DIR} + # Use the non-root user USER botuser From e802f25457a565bce8f8e89a34deb16b5e6eeebf Mon Sep 17 00:00:00 2001 From: Eric Bower <31257558+ebower42@users.noreply.github.com> Date: Thu, 23 Oct 2025 13:56:57 -0700 Subject: [PATCH 17/19] Eleven Labs Voice Generation (#7) * Added test file * Added eleven labs voice generation --------- Co-authored-by: Eric Bower --- bot.py | 11 ++++++++-- eleven_labs_api.py | 51 ++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | Bin 498 -> 536 bytes test_eleven_labs.py | 45 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 eleven_labs_api.py create mode 100644 test_eleven_labs.py diff --git a/bot.py b/bot.py index 5d7bbbd..9565a79 100644 --- a/bot.py +++ b/bot.py @@ -5,6 +5,7 @@ from typing import Any, Optional import logging from pathlib import Path +from eleven_labs_api import ElevenLabsAPI DEBUG = os.getenv("DEBUG") AUDIO_DIR = os.getenv("AUDIO_DIR", "/audio") @@ -14,6 +15,7 @@ RAW_PREFIX_SHORT = "!b" SPACE_PREFIX = RAW_PREFIX + " " BOT_TOKEN = os.getenv("BOT_TOKEN") +ELEVEN_LABS_TOKEN = os.getenv("ELEVEN_LABS_TOKEN") BOT_DESCRIPTION = ("A bot to generate alternate names for Benedict Cumberbatch.\n" "\n" f"Usage: {RAW_PREFIX_SHORT} [command] [arguments]\n" @@ -46,6 +48,7 @@ def add_indented_commands(self, _commands, *, heading, max_size=None): intents=intents, help_command=defaultHelpCommand) name_api = Generator() +eleven_labs_api = ElevenLabsAPI(ELEVEN_LABS_TOKEN) g_last_name = "Benedict Cumberbatch" g_last_phone = "benedict cumberbatch" @@ -62,8 +65,12 @@ async def _speak(ctx: commands.Context) -> Optional[Any]: if not vc or not vc.is_connected(): return await ctx.reply("I am not connected to a voice channel.", mention_author=False) if not vc.is_playing(): - name_api.vocalize(g_last_phone) - audio_source = Path(AUDIO_DIR) / "output.wav" + count = eleven_labs_api.get_remaining_character_count() + if count < 20: + name_api.vocalize(g_last_phone) + audio_source = Path(AUDIO_DIR) / "output.wav" + else: + audio_source = eleven_labs_api.get_spoken_name(g_last_name, AUDIO_DIR) return vc.play(discord.FFmpegPCMAudio(executable=FFMPEG_EXEC, source=str(audio_source))) else: return await ctx.reply("Audio is already playing.", mention_author=False) diff --git a/eleven_labs_api.py b/eleven_labs_api.py new file mode 100644 index 0000000..aab038b --- /dev/null +++ b/eleven_labs_api.py @@ -0,0 +1,51 @@ +from elevenlabs.client import ElevenLabs +from elevenlabs.types import VoiceSettings +from dataclasses import dataclass +from pathlib import Path +from typing import Union + +MODEL_ID = "eleven_turbo_v2_5" +OUTPUT_FORMAT = "mp3_44100_128" +MAX_CHARACTERS = 10000 + +class ElevenLabsAPI: + + @dataclass + class VoiceIDs: + Clyde = "wyWA56cQNU2KqUW4eCsI" + Charles = "zNsotODqUhvbJ5wMG7Ei" + + def __init__(self, token: str): + self.client = ElevenLabs(api_key=token) + self.character_count = 0 + self.update_character_count() + + def get_spoken_name(self, name: str, audio_dir: Union[Path, str], + voice_id: str = VoiceIDs.Charles, speed: float = 1.0, + regen: bool = False) -> Path: + name_id = name.replace(" ", "_") + file = Path(audio_dir) / f"{name_id}.mp3" + if file.exists() and not regen: + return file + + voice_settings = VoiceSettings(speed=speed) + audio_stream = self.client.text_to_speech.convert( + text=name, + voice_id=voice_id, + model_id=MODEL_ID, + output_format=OUTPUT_FORMAT, + voice_settings=voice_settings + ) + with open(file, "wb") as f: + for chunk in audio_stream: + f.write(chunk) + + self.update_character_count() + return file + + def update_character_count(self): + subscription = self.client.user.subscription.get() + self.character_count = subscription.character_count + + def get_remaining_character_count(self): + return MAX_CHARACTERS - self.character_count \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cdc18e9285cfe0bf63732f4fa2332df4be611aa0..76988ff64c2ac90f48d0b440aebc99ff2aa065b6 100644 GIT binary patch delta 49 xcmeywJcC8!|G!j*9EMbeG9bXHefzXIS4~Pwbq#*+r!$zhLi~vs;3n2gi delta 11 ScmbQi@`;)0|G$kI9~c22a0Occ diff --git a/test_eleven_labs.py b/test_eleven_labs.py new file mode 100644 index 0000000..ab5e4e7 --- /dev/null +++ b/test_eleven_labs.py @@ -0,0 +1,45 @@ +from elevenlabs.client import ElevenLabs +from elevenlabs.types import VoiceSettings +import os + +VOICE = "Charles" + +AUDIO_PATH_WIN = ("C:\\Users\\ebowe\\Programming\\Python\\CumberbatchNameGenerator\\audio\\eleven" + ".mp3") +AUDIO_PATH_MAC = "/Users/ebower/workspace/Personal/CumberbatchNameGeneratorBot/audio/eleven.mp3" + +VOICE_ID_MAP = { + "Clyde": "wyWA56cQNU2KqUW4eCsI", + "Charles": "zNsotODqUhvbJ5wMG7Ei" +} + +client = ElevenLabs( + api_key=os.getenv("ELEVEN_LABS_API_KEY"), +) + + +def main(): + global client + voice_settings = VoiceSettings(speed=1.0) + + audio_stream = client.text_to_speech.convert( + text="Babydust Supperrap", + voice_id=VOICE_ID_MAP[VOICE], + model_id="eleven_turbo_v2_5", + output_format="mp3_44100_128", + voice_settings=voice_settings, + ) + + with open(AUDIO_PATH_MAC, "wb") as audio_file: + for chunk in audio_stream: + audio_file.write(chunk) + + +def print_credits_usage(): + global client + subscription = client.user.subscription.get() + print(f"{subscription.character_count=}") + + +if __name__ == '__main__': + print_credits_usage() \ No newline at end of file From 24a1407c865fd97aedfe24b5f699d2c680d13424 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Fri, 24 Oct 2025 13:34:44 -0700 Subject: [PATCH 18/19] Added hidden count command --- bot.py | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/bot.py b/bot.py index 9565a79..bfae4c1 100644 --- a/bot.py +++ b/bot.py @@ -36,17 +36,20 @@ def add_indented_commands(self, _commands, *, heading, max_size=None): max_size = super().get_max_size(_commands) + self.indent return super().add_indented_commands(_commands, heading=heading, max_size=max_size) -defaultHelpCommand = CustomHelp( - show_parameter_descriptions=False, - no_category="Commands", - command_attrs={ - "aliases": ["?"], - "help": "Get general help information or help about a specific command" - } +bot = commands.Bot( + command_prefix=commands.when_mentioned_or(SPACE_PREFIX, "!b "), + description=BOT_DESCRIPTION, + intents=intents, + help_command=CustomHelp( + show_parameter_descriptions=False, + no_category="Commands", + command_attrs={ + "aliases": ["?"], + "help": "Get general help information or help about a specific command" + } + ) ) -bot = commands.Bot(command_prefix=commands.when_mentioned_or(SPACE_PREFIX, "!b "), description=BOT_DESCRIPTION, - intents=intents, - help_command=defaultHelpCommand) + name_api = Generator() eleven_labs_api = ElevenLabsAPI(ELEVEN_LABS_TOKEN) @@ -65,8 +68,8 @@ async def _speak(ctx: commands.Context) -> Optional[Any]: if not vc or not vc.is_connected(): return await ctx.reply("I am not connected to a voice channel.", mention_author=False) if not vc.is_playing(): - count = eleven_labs_api.get_remaining_character_count() - if count < 20: + _count = eleven_labs_api.get_remaining_character_count() + if _count < 20: name_api.vocalize(g_last_phone) audio_source = Path(AUDIO_DIR) / "output.wav" else: @@ -85,7 +88,6 @@ async def _gen(ctx: commands.Context): await _speak(ctx) - @bot.event async def on_ready(): print(f'Logged in as {bot.user.name} - {bot.user.id}') @@ -183,6 +185,14 @@ async def autospeak(ctx: commands.Context, subcmd: str = "on"): return await ctx.reply(f"Autospeak off") +# Hidden Commands +@bot.command(name="count", + hidden=True) +async def count(ctx: commands.Context): + cnt = eleven_labs_api.get_remaining_character_count() + return await ctx.reply(f"{cnt} characters") + + def run(token=BOT_TOKEN): if DEBUG: print(f"Using token: {token}") From 0835be4d8e7487d168e902dc42ae20a0f4f609b9 Mon Sep 17 00:00:00 2001 From: Eric Bower Date: Fri, 24 Oct 2025 17:09:12 -0500 Subject: [PATCH 19/19] Removed test file from git --- .gitignore | 1 + test_eleven_labs.py | 45 --------------------------------------------- 2 files changed, 1 insertion(+), 45 deletions(-) delete mode 100644 test_eleven_labs.py diff --git a/.gitignore b/.gitignore index d9c8e26..c0033c5 100644 --- a/.gitignore +++ b/.gitignore @@ -163,3 +163,4 @@ cython_debug/ #.idea/ /phonemize_words.py +/test_eleven_labs.py diff --git a/test_eleven_labs.py b/test_eleven_labs.py deleted file mode 100644 index ab5e4e7..0000000 --- a/test_eleven_labs.py +++ /dev/null @@ -1,45 +0,0 @@ -from elevenlabs.client import ElevenLabs -from elevenlabs.types import VoiceSettings -import os - -VOICE = "Charles" - -AUDIO_PATH_WIN = ("C:\\Users\\ebowe\\Programming\\Python\\CumberbatchNameGenerator\\audio\\eleven" - ".mp3") -AUDIO_PATH_MAC = "/Users/ebower/workspace/Personal/CumberbatchNameGeneratorBot/audio/eleven.mp3" - -VOICE_ID_MAP = { - "Clyde": "wyWA56cQNU2KqUW4eCsI", - "Charles": "zNsotODqUhvbJ5wMG7Ei" -} - -client = ElevenLabs( - api_key=os.getenv("ELEVEN_LABS_API_KEY"), -) - - -def main(): - global client - voice_settings = VoiceSettings(speed=1.0) - - audio_stream = client.text_to_speech.convert( - text="Babydust Supperrap", - voice_id=VOICE_ID_MAP[VOICE], - model_id="eleven_turbo_v2_5", - output_format="mp3_44100_128", - voice_settings=voice_settings, - ) - - with open(AUDIO_PATH_MAC, "wb") as audio_file: - for chunk in audio_stream: - audio_file.write(chunk) - - -def print_credits_usage(): - global client - subscription = client.user.subscription.get() - print(f"{subscription.character_count=}") - - -if __name__ == '__main__': - print_credits_usage() \ No newline at end of file