feat: add GetRandomQuoteAction
This commit is contained in:
parent
50900986fa
commit
75df191253
13 changed files with 185 additions and 218 deletions
26
devenv.lock
26
devenv.lock
|
|
@ -3,10 +3,10 @@
|
||||||
"devenv": {
|
"devenv": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"dir": "src/modules",
|
"dir": "src/modules",
|
||||||
"lastModified": 1735530587,
|
"lastModified": 1739362938,
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "devenv",
|
"repo": "devenv",
|
||||||
"rev": "69645885c1052cc1ca398ac30ba7dfc63386c0e3",
|
"rev": "27276816caa1718f8b8e8d53d64cc18da059e101",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -101,35 +101,19 @@
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs-stable": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1735286948,
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "31ac92f9628682b294026f0860e14587a09ffb4b",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixos-24.05",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pre-commit-hooks": {
|
"pre-commit-hooks": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-compat": "flake-compat_2",
|
"flake-compat": "flake-compat_2",
|
||||||
"gitignore": "gitignore",
|
"gitignore": "gitignore",
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
],
|
]
|
||||||
"nixpkgs-stable": "nixpkgs-stable"
|
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1734797603,
|
"lastModified": 1737465171,
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "pre-commit-hooks.nix",
|
"repo": "pre-commit-hooks.nix",
|
||||||
"rev": "f0f0dc4920a903c3e08f5bdb9246bb572fcae498",
|
"rev": "9364dc02281ce2d37a1f55b6e51f7c0f65a75f17",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
13
devenv.nix
13
devenv.nix
|
|
@ -5,24 +5,11 @@
|
||||||
|
|
||||||
packages = [ pkgs.git ];
|
packages = [ pkgs.git ];
|
||||||
|
|
||||||
certificates = [
|
|
||||||
"id.twitch.tv"
|
|
||||||
"twitch.tv"
|
|
||||||
"discord.com"
|
|
||||||
];
|
|
||||||
|
|
||||||
languages.python.enable = true;
|
languages.python.enable = true;
|
||||||
languages.python.uv.enable = true;
|
languages.python.uv.enable = true;
|
||||||
languages.python.version = "3.12.8";
|
languages.python.version = "3.12.8";
|
||||||
|
|
||||||
scripts.hello.exec = ''
|
|
||||||
echo hello from $GREET
|
|
||||||
'';
|
|
||||||
|
|
||||||
enterShell = ''
|
enterShell = ''
|
||||||
hello
|
|
||||||
git --version
|
|
||||||
fish
|
|
||||||
'';
|
'';
|
||||||
|
|
||||||
dotenv.enable = true;
|
dotenv.enable = true;
|
||||||
|
|
|
||||||
11
src/huesoporro/actions/get_random_quote.py
Normal file
11
src/huesoporro/actions/get_random_quote.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from src.huesoporro.models import Quote
|
||||||
|
from src.huesoporro.svc.get_random_quote import RandomQuoteGetterSvc
|
||||||
|
|
||||||
|
|
||||||
|
class GetRandomQuoteAction(BaseModel):
|
||||||
|
quote_getter_svc: RandomQuoteGetterSvc
|
||||||
|
|
||||||
|
async def run(self, channel_name: str) -> Quote | None:
|
||||||
|
return await self.quote_getter_svc.run(channel_name=channel_name)
|
||||||
|
|
@ -32,8 +32,6 @@ async def get_tts_permalink(access_token: str) -> Template:
|
||||||
"""Handler for the /tts permalink endpoint to be used by apps that can only give the authentication as a query
|
"""Handler for the /tts permalink endpoint to be used by apps that can only give the authentication as a query
|
||||||
param and not as a cookie, i.e. OBS"""
|
param and not as a cookie, i.e. OBS"""
|
||||||
|
|
||||||
# authenticate the user using the provided access token
|
|
||||||
|
|
||||||
return Template(
|
return Template(
|
||||||
template_name="tts.html",
|
template_name="tts.html",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from litestar import MediaType, get
|
from litestar import MediaType, get
|
||||||
|
from litestar.datastructures.cookie import Cookie
|
||||||
from litestar.response import Redirect, Template
|
from litestar.response import Redirect, Template
|
||||||
|
|
||||||
from src.huesoporro.actions.authenticate import AuthenticateAction
|
from src.huesoporro.actions.authenticate import AuthenticateAction
|
||||||
|
|
@ -10,7 +11,16 @@ from src.huesoporro.settings import Settings
|
||||||
@get(path="/o/code")
|
@get(path="/o/code")
|
||||||
async def get_code(code: str, authenticate_action: AuthenticateAction) -> Redirect:
|
async def get_code(code: str, authenticate_action: AuthenticateAction) -> Redirect:
|
||||||
token = await authenticate_action.run(code)
|
token = await authenticate_action.run(code)
|
||||||
return Redirect("/", cookies={"huesoporroAuth": token})
|
return Redirect(
|
||||||
|
"/",
|
||||||
|
cookies=[
|
||||||
|
Cookie(
|
||||||
|
key="huesoporroAuth",
|
||||||
|
value=token,
|
||||||
|
expires=604800, # 1 week
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@get(
|
@get(
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,11 @@ from loguru import logger
|
||||||
from twitchio import Channel
|
from twitchio import Channel
|
||||||
from twitchio.ext import commands, routines
|
from twitchio.ext import commands, routines
|
||||||
|
|
||||||
|
from src.huesoporro.actions.get_random_quote import GetRandomQuoteAction
|
||||||
from src.huesoporro.actions.store_quote import StoreQuoteAction
|
from src.huesoporro.actions.store_quote import StoreQuoteAction
|
||||||
|
from src.huesoporro.api.dependencies import get_settings
|
||||||
from src.huesoporro.infra.db import Database
|
from src.huesoporro.infra.db import Database
|
||||||
|
from src.huesoporro.infra.repos import QuoteRepo
|
||||||
from src.huesoporro.libs.db import Database as MarkovDB
|
from src.huesoporro.libs.db import Database as MarkovDB
|
||||||
from src.huesoporro.models import ChatbotSettings, User
|
from src.huesoporro.models import ChatbotSettings, User
|
||||||
from src.huesoporro.svc.backoff_service import BackoffService
|
from src.huesoporro.svc.backoff_service import BackoffService
|
||||||
|
|
@ -33,8 +36,11 @@ class Bot(commands.Bot):
|
||||||
self.store_quote_action = StoreQuoteAction(
|
self.store_quote_action = StoreQuoteAction(
|
||||||
quote_storer_svc=QuoteStorerSvc(db=db), is_mod_svc=IsModSvc(db=db)
|
quote_storer_svc=QuoteStorerSvc(db=db), is_mod_svc=IsModSvc(db=db)
|
||||||
)
|
)
|
||||||
|
self.quote_repo = QuoteRepo(s=get_settings())
|
||||||
self.get_random_quote_svc = RandomQuoteGetterSvc(db=db)
|
self.get_random_quote_svc = RandomQuoteGetterSvc(quote_repo=self.quote_repo)
|
||||||
|
self.get_random_quote_action = GetRandomQuoteAction(
|
||||||
|
quote_getter_svc=self.get_random_quote_svc
|
||||||
|
)
|
||||||
self.cbs = chatbot_settings
|
self.cbs = chatbot_settings
|
||||||
self.quote_routine = routines.routine(
|
self.quote_routine = routines.routine(
|
||||||
seconds=chatbot_settings.automatic_quote_timer, wait_first=True
|
seconds=chatbot_settings.automatic_quote_timer, wait_first=True
|
||||||
|
|
@ -78,19 +84,19 @@ class Bot(commands.Bot):
|
||||||
|
|
||||||
@commands.command(aliases=["q", "quote"])
|
@commands.command(aliases=["q", "quote"])
|
||||||
async def get_random_quote(self, ctx: commands.Context):
|
async def get_random_quote(self, ctx: commands.Context):
|
||||||
quote = await self.get_random_quote_svc.run(channel_name=self.channel)
|
quote = await self.get_random_quote_action.run(channel_name=self.channel)
|
||||||
if quote:
|
if quote:
|
||||||
await ctx.send(f"«{quote[0]}» - {quote[1]}")
|
await ctx.send(quote.as_pretty())
|
||||||
|
|
||||||
def get_channel_conn(self) -> Channel:
|
def get_channel_conn(self) -> Channel:
|
||||||
return Channel(name=self.channel, websocket=self._connection)
|
return Channel(name=self.channel, websocket=self._connection)
|
||||||
|
|
||||||
async def send_quote(self):
|
async def send_quote(self):
|
||||||
quote = await self.get_random_quote_svc.run(channel_name=self.channel)
|
quote = await self.get_random_quote_action.run(channel_name=self.channel)
|
||||||
if quote:
|
if quote:
|
||||||
channel = self.get_channel_conn()
|
channel = self.get_channel_conn()
|
||||||
logger.info(f"Sending random quote {quote[0]}")
|
logger.info(f"Sending random quote {quote.quote}")
|
||||||
await channel.send(f"«{quote[0]}» - {quote[1]}")
|
await channel.send(quote.quote)
|
||||||
|
|
||||||
async def send_generation(self):
|
async def send_generation(self):
|
||||||
sentence = await self.generate_svc.run()
|
sentence = await self.generate_svc.run()
|
||||||
|
|
|
||||||
48
src/huesoporro/infra/gtts.py
Normal file
48
src/huesoporro/infra/gtts.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
from collections import deque
|
||||||
|
from hashlib import sha512
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gtts import gTTS
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from src.huesoporro.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class GTTS(BaseModel):
|
||||||
|
s: Settings
|
||||||
|
chunk_size: int = 128
|
||||||
|
text_max_length: int = 100
|
||||||
|
queue: deque = deque()
|
||||||
|
|
||||||
|
async def generate(self, text: str, lang: str = "pt", tld="com.br") -> Path:
|
||||||
|
text = text[: self.text_max_length]
|
||||||
|
raw_filename = f"{text.lower()}_{lang}_{tld}"
|
||||||
|
logger.info(f"Generating TTS for {raw_filename}")
|
||||||
|
filepath = (
|
||||||
|
self.s.tts_cache_path / f"{sha512(raw_filename.encode()).hexdigest()}.mp3"
|
||||||
|
)
|
||||||
|
tts = gTTS(text=text, lang=lang, tld=tld)
|
||||||
|
logger.info(f"Saving TTS to {filepath}")
|
||||||
|
tts.save(str(filepath))
|
||||||
|
self.queue.append(filepath)
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
async def consume(self):
|
||||||
|
"""If there are items in the queue, return a generator
|
||||||
|
that reads the file's bytes by chunks of self.chunk_size"""
|
||||||
|
while self.queue:
|
||||||
|
filepath = self.queue.popleft()
|
||||||
|
if not filepath.exists():
|
||||||
|
logger.warning(f"File {filepath} does not exist, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Reading file {filepath}")
|
||||||
|
try:
|
||||||
|
with filepath.open("rb") as f:
|
||||||
|
while chunk := f.read(self.chunk_size):
|
||||||
|
yield chunk
|
||||||
|
logger.info(f"Finished reading {filepath}")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.error(f"Error reading file {filepath}: {e}")
|
||||||
|
continue
|
||||||
|
|
@ -6,7 +6,7 @@ from typing import Generic, TypeVar
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from src.huesoporro.models import User
|
from src.huesoporro.models import Quote, User
|
||||||
from src.huesoporro.settings import Settings
|
from src.huesoporro.settings import Settings
|
||||||
|
|
||||||
T = TypeVar("T", bound=BaseModel)
|
T = TypeVar("T", bound=BaseModel)
|
||||||
|
|
@ -112,3 +112,46 @@ class UserRepo(IRepo[User]):
|
||||||
|
|
||||||
async def count(self, obj: User, auto_commit=True):
|
async def count(self, obj: User, auto_commit=True):
|
||||||
raise NotImplementedError("Not implemented since it's not needed")
|
raise NotImplementedError("Not implemented since it's not needed")
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteRepo(IRepo[Quote]):
|
||||||
|
async def create(self, obj: Quote, auto_commit=True) -> Quote:
|
||||||
|
raise NotImplementedError("Not implemented since it's not needed")
|
||||||
|
|
||||||
|
async def update(self, obj: Quote, auto_commit=True) -> Quote:
|
||||||
|
raise NotImplementedError("Not implemented since it's not needed")
|
||||||
|
|
||||||
|
async def delete(self, obj: Quote, auto_commit=True):
|
||||||
|
raise NotImplementedError("Not implemented since it's not needed")
|
||||||
|
|
||||||
|
async def get_by_id(self, obj_id: int | str, auto_commit=True) -> Quote | None:
|
||||||
|
raise NotImplementedError("Not implemented since it's not needed")
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self, obj: T, offset: int = 0, limit: int = 10, auto_commit=True
|
||||||
|
) -> list[T]:
|
||||||
|
raise NotImplementedError("Not implemented since it's not needed")
|
||||||
|
|
||||||
|
async def get_random(self, channel_name: str, auto_commit=True) -> Quote | None:
|
||||||
|
async with (
|
||||||
|
self.get_client(auto_commit=auto_commit) as db,
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM quotes
|
||||||
|
WHERE channel = ?
|
||||||
|
ORDER BY RANDOM()
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(channel_name,),
|
||||||
|
) as cursor,
|
||||||
|
):
|
||||||
|
data = await cursor.fetchone()
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
return Quote(
|
||||||
|
quote=data["quote"],
|
||||||
|
author=User(user=data["author"], external_auth={}),
|
||||||
|
channel=User(user=data["channel"], external_auth={}),
|
||||||
|
created_at=data["created_at"],
|
||||||
|
last_updated_at=data["last_updated_at"],
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
|
|
@ -69,3 +70,17 @@ class Sentence(BaseModel):
|
||||||
created_at: float
|
created_at: float
|
||||||
last_updated_at: float
|
last_updated_at: float
|
||||||
user: User
|
user: User
|
||||||
|
|
||||||
|
|
||||||
|
class Quote(BaseModel):
|
||||||
|
quote: str
|
||||||
|
author: User
|
||||||
|
channel: User
|
||||||
|
created_at: datetime.datetime
|
||||||
|
last_updated_at: datetime.datetime
|
||||||
|
|
||||||
|
def as_pretty(self) -> str:
|
||||||
|
return f"«{self.quote}» - {self.author}"
|
||||||
|
|
||||||
|
def as_pretty_saved(self):
|
||||||
|
return f"He añadido la cita «{self.quote}» de {self.author}"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from src.huesoporro.infra.db import Database
|
from src.huesoporro.infra.repos import QuoteRepo
|
||||||
|
from src.huesoporro.models import Quote
|
||||||
|
|
||||||
|
|
||||||
class RandomQuoteGetterSvc(BaseModel):
|
class RandomQuoteGetterSvc(BaseModel):
|
||||||
db: Database
|
quote_repo: QuoteRepo
|
||||||
|
|
||||||
async def run(self, channel_name: str) -> tuple[str, str] | None:
|
async def run(self, channel_name: str) -> Quote | None:
|
||||||
return await self.db.get_random_quote(channel_name=channel_name)
|
return await self.quote_repo.get_random(channel_name=channel_name)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<nav class="container">
|
<nav class="container">
|
||||||
<ul>
|
<ul>
|
||||||
<li>Chatbot</li>
|
<li>Chatbot</li>
|
||||||
<li><a href="#" disabled>TTS</a></li>
|
<li><a href="/tts" disabled>TTS</a></li>
|
||||||
{% include 'le_funny_dropdown.html' %}
|
{% include 'le_funny_dropdown.html' %}
|
||||||
</ul>
|
</ul>
|
||||||
{% include 'logout.html' %}
|
{% include 'logout.html' %}
|
||||||
|
|
|
||||||
|
|
@ -2,194 +2,41 @@
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<nav id="navbar">
|
<nav class="container">
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/">Chatbot</a></li>
|
<li><a href="/">Chatbot</a></li>
|
||||||
<li>TTS</li>
|
<li>TTS</li>
|
||||||
<li><a href="/lefunny">Le Funny</a></li>
|
{% include 'le_funny_dropdown.html' %}
|
||||||
</ul>
|
</ul>
|
||||||
{% include 'logout.html' %}
|
{% include 'logout.html' %}
|
||||||
</nav>
|
</nav>
|
||||||
<h1>Huesoporro🦴🍃</h1>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main class="container">
|
||||||
<section>
|
<section>
|
||||||
<form>
|
<form>
|
||||||
<label for="textInput">Enter text:</label>
|
<label for="textInput">Enter text:</label>
|
||||||
<input type="text" id="textInput" placeholder="Hi huesoporro">
|
<input type="text" id="textInput" placeholder="Hi huesoporro">
|
||||||
<button id="sendButton" type="button">Send text</button>
|
<button id="sendButton" type="button">Send text</button>
|
||||||
<button id="genPermalinkButton" type="button" style="background-color: #9c36b5; border-color: #9c36b5">Generate OBS Link</button>
|
<button id="genPermalinkButton" type="button" style="background-color: #9c36b5; border-color: #9c36b5">
|
||||||
|
Generate OBS Link
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<audio id="audioPlayer" hidden="hidden" controls></audio>
|
<audio id="audioPlayer" hidden="hidden" controls></audio>
|
||||||
<details open="open">
|
<details>
|
||||||
<summary>Log</summary>
|
<summary>Log</summary>
|
||||||
<div><samp id="log"></samp></div>
|
<div><samp id="log"></samp></div>
|
||||||
</details>
|
</details>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
class AudioStreamer {
|
const logElement = document.getElementById('log');
|
||||||
constructor() {
|
const eventSource = new EventSource('/api/v1/sse/tts');
|
||||||
this.url = getWebsocketProtocol() + window.location.host + "/ws";
|
eventSource.onmessage = (event) => {
|
||||||
this.audioPlayer = document.getElementById('audioPlayer');
|
logElement.innerHTML += event.data + '<br>';
|
||||||
this.logElement = document.getElementById('log');
|
};
|
||||||
this.audioBuffer = [];
|
})
|
||||||
this.expectedFileSize = 0;
|
|
||||||
this.receivedFileSize = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
log(message) {
|
|
||||||
console.log(message);
|
|
||||||
this.logElement.innerHTML += message + '<br>';
|
|
||||||
}
|
|
||||||
|
|
||||||
async start() {
|
|
||||||
// Establish WebSocket connection
|
|
||||||
this.audioBuffer = [];
|
|
||||||
this.expectedFileSize = 0;
|
|
||||||
this.receivedFileSize = 0;
|
|
||||||
this.log("Connecting to WebSocket: " + this.url);
|
|
||||||
this.websocket = new WebSocket(this.url);
|
|
||||||
this.websocket.withCredentials = true;
|
|
||||||
this.websocket.onopen = () => {
|
|
||||||
this.log('WebSocket connection established');
|
|
||||||
};
|
|
||||||
this.websocket.onmessage = async (event) => {
|
|
||||||
try {
|
|
||||||
if (typeof event.data === 'string') {
|
|
||||||
if (event.data.startsWith('FILE_HEADER:')) {
|
|
||||||
this.expectedFileSize = parseInt(event.data.split(':')[1]);
|
|
||||||
this.log(`Expecting file of size: ${this.expectedFileSize} bytes`);
|
|
||||||
this.audioBuffer = [];
|
|
||||||
this.receivedFileSize = 0;
|
|
||||||
} else if (event.data === 'FILE_FOOTER') {
|
|
||||||
// Only play after file footer is received and all chunks are in
|
|
||||||
this.log(`Received complete file. Total size: ${this.receivedFileSize} bytes`);
|
|
||||||
if (this.receivedFileSize > 0) {
|
|
||||||
await this.playAudioBuffer();
|
|
||||||
} else {
|
|
||||||
this.log('No audio data received');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Accumulate chunks
|
|
||||||
const audioData = await event.data.arrayBuffer();
|
|
||||||
this.audioBuffer.push(audioData);
|
|
||||||
this.receivedFileSize += audioData.byteLength;
|
|
||||||
|
|
||||||
this.log(`Received chunk. Total received: ${this.receivedFileSize} / ${this.expectedFileSize}`);
|
|
||||||
|
|
||||||
if (this.receivedFileSize >= this.expectedFileSize) {
|
|
||||||
// Play audio when complete
|
|
||||||
await this.playAudioBuffer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Error processing audio: ${error}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.websocket.onerror = (error) => {
|
|
||||||
this.log(`WebSocket error: ${error}`);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async combineBuffers(buffers) {
|
|
||||||
console.log(`Combining ${buffers.length} buffers`);
|
|
||||||
buffers.forEach((buffer, index) => {
|
|
||||||
console.log(`Buffer ${index} size: ${buffer.byteLength} bytes`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Validate buffers
|
|
||||||
if (buffers.length === 0) {
|
|
||||||
console.error('No buffers to combine');
|
|
||||||
return new ArrayBuffer(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate total length
|
|
||||||
const totalLength = buffers.reduce((acc, buffer) => acc + buffer.byteLength, 0);
|
|
||||||
console.log(`Total combined length: ${totalLength} bytes`);
|
|
||||||
|
|
||||||
// Create a new buffer and copy data
|
|
||||||
const combinedBuffer = new Uint8Array(totalLength);
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
for (const buffer of buffers) {
|
|
||||||
combinedBuffer.set(new Uint8Array(buffer), offset);
|
|
||||||
offset += buffer.byteLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
return combinedBuffer.buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
async playAudioBuffer() {
|
|
||||||
try {
|
|
||||||
const combinedBuffer = await this.combineBuffers(this.audioBuffer);
|
|
||||||
|
|
||||||
// Verify combined buffer
|
|
||||||
console.log(`Combined buffer size: ${combinedBuffer.byteLength} bytes`);
|
|
||||||
|
|
||||||
const blob = new Blob([combinedBuffer], {type: 'audio/mpeg'});
|
|
||||||
|
|
||||||
console.log(`Blob size: ${blob.size} bytes`);
|
|
||||||
|
|
||||||
// Only proceed if blob has content
|
|
||||||
if (blob.size > 0) {
|
|
||||||
this.audioPlayer.src = URL.createObjectURL(blob);
|
|
||||||
await this.audioPlayer.play();
|
|
||||||
} else {
|
|
||||||
console.error('Blob is empty');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Audio buffer processing error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendText(text) {
|
|
||||||
// build a Websocket message and send it
|
|
||||||
|
|
||||||
const message = {
|
|
||||||
"command": "tts_send",
|
|
||||||
"data": {
|
|
||||||
"text": text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
|
||||||
this.websocket.send(JSON.stringify(message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioStreamer = new AudioStreamer();
|
|
||||||
const sendButton = document.getElementById('sendButton');
|
|
||||||
const textInput = document.getElementById('textInput');
|
|
||||||
|
|
||||||
// Automatically connect on page load
|
|
||||||
audioStreamer.start();
|
|
||||||
|
|
||||||
sendButton.addEventListener('click', () => {
|
|
||||||
const text = textInput.value.trim();
|
|
||||||
if (text) {
|
|
||||||
audioStreamer.sendText(text);
|
|
||||||
textInput.value = ''; // Clear input
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
addLogoutEvent()
|
|
||||||
|
|
||||||
const genPermalinkButton = document.getElementById('genPermalinkButton');
|
|
||||||
genPermalinkButton.addEventListener('click', () => {
|
|
||||||
// generate <ur>/tts/permalink?access_token=<access_token>
|
|
||||||
// the access token is available in the twitchLoginData cookie
|
|
||||||
|
|
||||||
const cookie = JSON.parse(getCookie("huesoporroAuth"))
|
|
||||||
const permalinkUrl = `${window.location.origin}/tts/permalink?access_token=${cookie.access_token}`;
|
|
||||||
navigator.clipboard.writeText(permalinkUrl);
|
|
||||||
alert('OBS link copied to clipboard ' + permalinkUrl);
|
|
||||||
})
|
|
||||||
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.huesoporro.infra.repos import UserRepo
|
from src.huesoporro.infra.repos import QuoteRepo, UserRepo
|
||||||
from src.huesoporro.models import User
|
from src.huesoporro.models import User
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -17,6 +17,16 @@ async def user_repo(s, db, user: User):
|
||||||
return UserRepo(s=s)
|
return UserRepo(s=s)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def quote_repo(s, db):
|
||||||
|
async with db.get_client() as client:
|
||||||
|
await client.execute(
|
||||||
|
"INSERT INTO quotes (channel, quote, author) VALUES (?, ?, ?)",
|
||||||
|
("channel", "quote", "author"),
|
||||||
|
)
|
||||||
|
return QuoteRepo(s=s)
|
||||||
|
|
||||||
|
|
||||||
async def test_get_user(user_repo: UserRepo, user: User):
|
async def test_get_user(user_repo: UserRepo, user: User):
|
||||||
db_user = await user_repo.get_by_user(user.user)
|
db_user = await user_repo.get_by_user(user.user)
|
||||||
assert db_user == user
|
assert db_user == user
|
||||||
|
|
@ -51,3 +61,10 @@ async def test_update_non_existing_user_raises_value_error(user_repo: UserRepo):
|
||||||
async def test_delete_user(user_repo: UserRepo, user: User):
|
async def test_delete_user(user_repo: UserRepo, user: User):
|
||||||
assert await user_repo.delete(user) is None
|
assert await user_repo.delete(user) is None
|
||||||
assert await user_repo.get_by_user(user.user) is None
|
assert await user_repo.get_by_user(user.user) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_random_quote(quote_repo: QuoteRepo):
|
||||||
|
quote = await quote_repo.get_random("channel")
|
||||||
|
assert quote
|
||||||
|
assert quote.author.user == "author"
|
||||||
|
assert quote.channel.user == "channel"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue