diff --git a/aces/connection_manager.py b/aces/connection_manager.py new file mode 100644 index 0000000..fa55569 --- /dev/null +++ b/aces/connection_manager.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from typing import List + +class ConnectionManager: + def __init__(self): + self.active_connections: List[WebSocket] = [] + + async def connect(self, websocket: WebSocket): + self.active_connections.append(websocket) + + async def disconnect(self, websocket: WebSocket): + if websocket in self.active_connections: + self.active_connections.remove(websocket) + + async def broadcast_raw(self, data: bytes): + for connection in self.active_connections: + try: + await connection.send_bytes(data) + except WebSocketDisconnect: + await self.disconnect(connection) + except Exception as e: + print(f"Error sending to client: {e}") + diff --git a/aces/flac_reader.py b/aces/flac_reader.py new file mode 100644 index 0000000..d495277 --- /dev/null +++ b/aces/flac_reader.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3.12 + +import soundfile as sf +import asyncio +import numpy as np +import time + +from aces.connection_manager import ConnectionManager +from fastapi import WebSocket, WebSocketDisconnect + + +class AudioStreamer: + def __init__(self, sound_file, ws_connection_mgr): + self.sound_file = sound_file + self.audio_file = None + self.format_info = None + self.chunk_size = 16384 # Larger chunk for better quality + self.ws_connection_mgr = ws_connection_mgr + self.broadcast_task = None + self.init_audio_file() + + def init_audio_file(self): + self.audio_file = sf.SoundFile(self.sound_file, 'rb') + self.format_info = { + 'samplerate': self.audio_file.samplerate, + 'channels': self.audio_file.channels, + 'format': 'PCM', + 'subtype': str(self.audio_file.subtype) + } + + async def broadcast_audio(self): + try: + chunk_duration = self.chunk_size / (self.audio_file.samplerate * self.audio_file.channels) + target_interval = chunk_duration / 2 # Send chunks at twice playback rate for smooth buffering + + while True: + if not self.ws_connection_mgr.active_connections: + await asyncio.sleep(0.1) + continue + + start_time = asyncio.get_event_loop().time() + + chunk = self.audio_file.read(self.chunk_size, dtype='float32') + if len(chunk) == 0: + self.audio_file.seek(0) + continue + + await self.ws_connection_mgr.broadcast_raw(chunk.tobytes()) + + # Calculate how long processing took and adjust sleep time + elapsed = asyncio.get_event_loop().time() - start_time + sleep_time = max(0, target_interval - elapsed) + await asyncio.sleep(sleep_time) + + except Exception as e: + print(f"Broadcast error: {e}") + + async def handle_client(self, ws: WebSocket): + try: + await self.ws_connection_mgr.connect(ws) + await ws.send_json(self.format_info) + + if not self.broadcast_task or self.broadcast_task.done(): + self.broadcast_task = asyncio.create_task(self.broadcast_audio()) + + while True: + try: + await ws.receive_text() + except WebSocketDisconnect: + break + + except Exception as e: + print(f"Error in handle_client: {e}") + finally: + await self.ws_connection_mgr.disconnect(ws) \ No newline at end of file diff --git a/base.py b/base.py index 42ed2b7..cf128b2 100644 --- a/base.py +++ b/base.py @@ -33,6 +33,7 @@ glob_state = importlib.import_module("state").State(app, util, constants) origins = [ "https://codey.lol", + "https://api.codey.lol" ] app.add_middleware(CORSMiddleware, @@ -84,8 +85,6 @@ lastfm_endpoints = importlib.import_module("endpoints.lastfm").LastFM(app, util, yt_endpoints = importlib.import_module("endpoints.yt").YT(app, util, constants, glob_state) # Below: XC endpoint(s) xc_endpoints = importlib.import_module("endpoints.xc").XC(app, util, constants, glob_state) -# Below: CAH endpoint(s) -# cah_endpoints = importlib.import_module("endpoints.cah").CAH(app, util, constants, glob_state) # Below: Karma endpoint(s) karma_endpoints = importlib.import_module("endpoints.karma").Karma(app, util, constants, glob_state) diff --git a/endpoints/ai.py b/endpoints/ai.py index 64fd8ac..f2ca5f1 100644 --- a/endpoints/ai.py +++ b/endpoints/ai.py @@ -40,6 +40,7 @@ class AI(FastAPI): """ /ai/base/ AI BASE Request + (Requires key) """ if not self.util.check_key(request.url.path, request.headers.get('X-Authd-With')): @@ -70,6 +71,7 @@ class AI(FastAPI): """ /ai/openai/ AI Request + (Requires key) """ if not self.util.check_key(request.url.path, request.headers.get('X-Authd-With')): @@ -145,9 +147,16 @@ class AI(FastAPI): await self.glob_state.increment_counter('claude_ai_requests') response = await request.json() print(f"Response: {response}") - result = { - 'resp': response.get('content')[0].get('text').strip() - } + if response.get('type') == 'error': + error_type = response.get('error').get('type') + error_message = response.get('error').get('message') + result = { + 'resp': f"{error_type} error ({error_message})" + } + else: + result = { + 'resp': response.get('content')[0].get('text').strip() + } return result except Exception as e: # pylint: disable=broad-exception-caught logging.error("Error: %s", e) diff --git a/endpoints/lyric_search.py b/endpoints/lyric_search.py index 96a6960..92126fe 100644 --- a/endpoints/lyric_search.py +++ b/endpoints/lyric_search.py @@ -3,6 +3,8 @@ import importlib import urllib.parse import regex +import aiohttp +import traceback from fastapi import FastAPI, HTTPException from pydantic import BaseModel @@ -14,6 +16,7 @@ class ValidLyricRequest(BaseModel): - **s**: song - **t**: track (artist and song combined) [used only if a & s are not used] - **extra**: include extra details in response [optional, default: false] + - **lrc**: Request LRCs? - **sub**: text to search within lyrics, if found lyrics will begin at found verse [optional] - **src**: the script/utility which initiated the request """ @@ -23,6 +26,7 @@ class ValidLyricRequest(BaseModel): t: str | None = None sub: str | None = None extra: bool | None = False + lrc: bool | None = False src: str class Config: # pylint: disable=missing-class-docstring too-few-public-methods @@ -31,37 +35,11 @@ class ValidLyricRequest(BaseModel): "a": "eminem", "s": "rap god", "src": "WEB", - "extra": True + "extra": True, + "lrc": False, } } - -class ValidLyricRequest(BaseModel): - """ - - **a**: artist - - **s**: song - - **t**: track (artist and song combined) [used only if a & s are not used] - - **extra**: include extra details in response [optional, default: false] - - **sub**: text to search within lyrics, if found lyrics will begin at found verse [optional] - - **src**: the script/utility which initiated the request - """ - - a: str | None = None - s: str | None = None - t: str | None = None - sub: str | None = None - extra: bool | None = False - src: str - - class Config: # pylint: disable=missing-class-docstring too-few-public-methods - schema_extra = { - "example": { - "a": "eminem", - "s": "rap god", - "src": "WEB", - "extra": True - } - } class ValidLyricSearchLogRequest(BaseModel): """ @@ -99,6 +77,8 @@ class LyricSearch(FastAPI): "IRC-SHARED" ] + self.lrc_regex = regex.compile(r'\[([0-9]{2}:[0-9]{2})\.[0-9]{1,3}\](\s(.*)){0,}') + for endpoint, handler in self.endpoints.items(): app.add_api_route(f"/{endpoint}/", handler, methods=["POST"]) @@ -129,10 +109,12 @@ class LyricSearch(FastAPI): - **s**: song - **t**: track (artist and song combined) [used only if a & s are not used] - **extra**: include extra details in response [optional, default: false] + - **lrc**: Request LRCs? - **sub**: text to search within lyrics, if found lyrics will begin at found verse [optional, default: none] - **src**: the script/utility which initiated the request """ + lrc = data.lrc src = data.src.upper() if not src in self.acceptable_request_sources: raise HTTPException(detail="Invalid request source", status_code=403) @@ -175,8 +157,88 @@ class LyricSearch(FastAPI): else: search_object = self.lyrics_engine.create_query_object(str(search_text)) + if lrc: + search_worker = await self.lyrics_engine.grabFromSpotify(searching=search_object, + lrc=True) + + spotify_lyrics_unsynced = True + if search_worker and search_worker.get('l'): + for line in search_worker.get('l'): + if line.get('timeTag') and line.get('timeTag') != "00:00.00": + spotify_lyrics_unsynced = False + if not search_worker or spotify_lyrics_unsynced: + # Try LRCLib before failing out + try: + lrclib_api_url = "https://lrclib.net/api/get" + sane_artist = urllib.parse.quote_plus(search_artist) + sane_track = urllib.parse.quote_plus(search_song) + async with aiohttp.ClientSession() as session: + async with session.get(f"{lrclib_api_url}?artist_name={sane_artist}&track_name={sane_track}") as request: + request.raise_for_status() + response_json = await request.json() + if not "syncedLyrics" in response_json: + raise BaseException("LRCLib Fallback Failed") + lrc_content = response_json.get('syncedLyrics') + returned_artist = response_json.get('artistName') + returned_song = response_json.get('trackName') + print(f"Synced Lyrics [LRCLib]: {lrc_content}") + lrc_content_out = [] + for line in lrc_content.split("\n"): + _timetag = None + _words = None + if not line.strip(): + continue + reg_helper = regex.findall(self.lrc_regex, line.strip()) + if not reg_helper: + continue + reg_helper = reg_helper[0] + print(f"Reg helper: {reg_helper} for line: {line}; len: {len(reg_helper)}") + _timetag = reg_helper[0] + if not reg_helper[1].strip(): + _words = "♪" + else: + _words = reg_helper[1] + lrc_content_out.append({ + "timeTag": _timetag, + "words": _words, + }) + + return { + 'err': False, + 'artist': returned_artist, + 'song': returned_song, + 'combo_lev': "N/A", + 'lrc': lrc_content_out, + 'from_cache': False, + 'src': 'Alt LRC SRC', + 'reqn': await self.glob_state.get_counter('lyric_requests'), + } + except: + print(traceback.format_exc()) + return { + 'err': True, + 'errorText': 'Search failed!', + } + + + return { + 'err': True, + 'errorText': 'Search failed!', + } + return { + 'err': False, + 'artist': search_worker['artist'], + 'song': search_worker['song'], + 'combo_lev': search_worker['combo_lev'], + 'lrc': search_worker['l'], + 'from_cache': False, + 'src': search_worker['method'], + 'reqn': await self.glob_state.get_counter('lyric_requests'), + } + search_worker = await self.lyrics_engine.lyrics_worker(searching=search_object, recipient='anyone') + if not search_worker or not 'l' in search_worker.keys(): await self.glob_state.increment_counter('failedlyric_requests') diff --git a/endpoints/xc.py b/endpoints/xc.py index 024fcc0..5cee27b 100644 --- a/endpoints/xc.py +++ b/endpoints/xc.py @@ -1,8 +1,21 @@ #!/usr/bin/env python3.12 -from fastapi import FastAPI, Request, HTTPException +from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect, WebSocketException from pydantic import BaseModel from aiohttp import ClientSession, ClientTimeout +from aces.connection_manager import ConnectionManager +from aces.flac_reader import AudioStreamer + +import os +import asyncio +import pyaudio +import wave +import traceback +import pyflac.decoder as decoder +import numpy as np +import soundfile as sf +import json +import time class ValidXCRequest(BaseModel): """ @@ -17,6 +30,8 @@ class ValidXCRequest(BaseModel): cmd: str data: dict | None = None + + class XC(FastAPI): """XC (CrossComm) Endpoints""" def __init__(self, app: FastAPI, util, constants, glob_state): # pylint: disable=super-init-not-called @@ -25,14 +40,26 @@ class XC(FastAPI): self.constants = constants self.glob_state = glob_state + self.ws_endpoints = { + # "aces_ws_put": self.put_ws_handler, + } + self.endpoints = { "xc": self.xc_handler, #tbd } + + + for endpoint, handler in self.ws_endpoints.items(): + app.add_api_websocket_route(f"/{endpoint}/", handler) for endpoint, handler in self.endpoints.items(): app.add_api_route(f"/{endpoint}/", handler, methods=["POST"]) - + + # async def put_ws_handler(self, ws: WebSocket): + # await ws.accept() + # await self.audio_streamer.handle_client(ws) + async def xc_handler(self, data: ValidXCRequest, request: Request): """ /xc/