from fastapi import FastAPI, WebSocket, WebSocketDisconnect import uvicorn import asyncio import os import subprocess import struct import base64 import time import hashlib import json from dotenv import load_dotenv import dashscope from dashscope.audio.asr import Recognition, RecognitionCallback, RecognitionResult from dashscope import ImageSynthesis from dashscope import Generation # 加载环境变量 load_dotenv() dashscope.api_key = os.getenv("DASHSCOPE_API_KEY") app = FastAPI() # 字体文件配置 FONT_FILE = "GB2312-16.bin" FONT_CHUNK_SIZE = 512 HIGH_FREQ_CHARS = "的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多定行学法所民得经十三之进着等部度家电力里如水化高自二理起小物现实加量都两体制机当使点从业本去把性好应开它合还因由其些然前外天政四日那社义事平形相全表间样与关各重新线内数正心反你明看原又么利比或但质气第向道命此变条只没结解问意建月公无系军很情者最立代想已通并提直题党程展五果料象员革位入常文总次品式活设及管特件长求老头基资边流路级少图山统接知较将组见计别她手角期根论运农指几九区强放决西被干做必战先回则任取据处队南给色光门即保治北造百规热领七海口东导器压志世金增争济阶油思术极交受联什认六共权收证改清己美再采转更单风切打白教速花带安场身车例真务具万每目至达走积示议声报斗完类八离华名确才科张信马节话米整空元况今集温传土许步群广石记需段研界拉林律叫且究观越织装影算低持音众书布复容儿须际商非验连断深难近矿千周委素技备半办青省列习响约支般史感劳便团往酸历市克何除消构府称太准精值号率族维划选标写存候毛亲快效斯院查江型眼王按格养易置派层片始却专状育厂京识适属圆包火住调满县局照参红细引听该铁价严龙飞" # 高频字对应的Unicode码点列表 HIGH_FREQ_UNICODE = [ord(c) for c in HIGH_FREQ_CHARS] # 字体缓存 font_cache = {} font_md5 = {} font_data_buffer = None def calculate_md5(filepath): """计算文件的MD5哈希值""" if not os.path.exists(filepath): return None hash_md5 = hashlib.md5() with open(filepath, "rb") as f: for chunk in iter(lambda: f.read(4096), b""): hash_md5.update(chunk) return hash_md5.hexdigest() def init_font_cache(): """初始化字体缓存和MD5""" global font_cache, font_md5, font_data_buffer script_dir = os.path.dirname(os.path.abspath(__file__)) font_path = os.path.join(script_dir, FONT_FILE) if not os.path.exists(font_path): font_path = os.path.join(script_dir, "..", FONT_FILE) if os.path.exists(font_path): font_md5 = calculate_md5(font_path) print(f"Font MD5: {font_md5}") # 加载整个字体文件到内存 try: with open(font_path, "rb") as f: font_data_buffer = f.read() print(f"Loaded font file into memory: {len(font_data_buffer)} bytes") except Exception as e: print(f"Error loading font file: {e}") font_data_buffer = None # 预加载高频字到缓存 (仍然保留以便快速访问) for unicode_val in HIGH_FREQ_UNICODE: get_font_data(unicode_val) print(f"Preloaded {len(font_cache)} high-frequency characters") # 启动时初始化字体缓存 init_font_cache() # 存储接收到的音频数据 audio_buffer = bytearray() RECORDING_RAW_FILE = "received_audio.raw" RECORDING_MP3_FILE = "received_audio.mp3" VOLUME_GAIN = 10.0 GENERATED_IMAGE_FILE = "generated_image.png" GENERATED_THUMB_FILE = "generated_thumb.bin" OUTPUT_DIR = "output_images" if not os.path.exists(OUTPUT_DIR): os.makedirs(OUTPUT_DIR) image_counter = 0 def get_output_path(): global image_counter image_counter += 1 timestamp = time.strftime("%Y%m%d_%H%M%S") return os.path.join(OUTPUT_DIR, f"image_{timestamp}_{image_counter}.png") THUMB_SIZE = 245 # 字体请求队列(用于重试机制) font_request_queue = {} FONT_RETRY_MAX = 3 # 图片生成任务管理 class ImageGenerationTask: """图片生成任务管理类""" def __init__(self, task_id: str, asr_text: str, websocket: WebSocket): self.task_id = task_id self.asr_text = asr_text self.websocket = websocket self.status = "pending" # pending, optimizing, generating, completed, failed self.progress = 0 self.message = "" self.result = None self.error = None # 存储活跃的图片生成任务 active_tasks = {} task_counter = 0 async def start_async_image_generation(websocket: WebSocket, asr_text: str): """异步启动图片生成任务,不阻塞WebSocket连接""" global task_counter, active_tasks task_id = f"task_{task_counter}_{int(time.time() * 1000)}" task_counter += 1 task = ImageGenerationTask(task_id, asr_text, websocket) active_tasks[task_id] = task print(f"Starting async image generation task: {task_id}") await websocket.send_text(f"TASK_ID:{task_id}") def progress_callback(progress: int, message: str): """进度回调函数""" task.progress = progress task.message = message try: asyncio.run_coroutine_threadsafe( websocket.send_text(f"TASK_PROGRESS:{task_id}:{progress}:{message}"), asyncio.get_event_loop() ) except Exception as e: print(f"Error sending progress: {e}") try: task.status = "optimizing" await websocket.send_text("STATUS:OPTIMIZING:正在优化提示词...") await asyncio.sleep(0.2) optimized_prompt = await asyncio.to_thread(optimize_prompt, asr_text, progress_callback) await websocket.send_text(f"PROMPT:{optimized_prompt}") task.optimized_prompt = optimized_prompt task.status = "generating" await websocket.send_text("STATUS:RENDERING:正在生成图片,请稍候...") await asyncio.sleep(0.2) image_path = await asyncio.to_thread(generate_image, optimized_prompt, progress_callback) task.result = image_path if image_path and os.path.exists(image_path): task.status = "completed" await websocket.send_text("STATUS:COMPLETE:图片生成完成") await asyncio.sleep(0.2) await send_image_to_client(websocket, image_path) else: task.status = "failed" task.error = "图片生成失败" await websocket.send_text("IMAGE_ERROR:图片生成失败") await websocket.send_text("STATUS:ERROR:图片生成失败") except Exception as e: task.status = "failed" task.error = str(e) print(f"Image generation task error: {e}") await websocket.send_text(f"IMAGE_ERROR:图片生成出错: {str(e)}") await websocket.send_text("STATUS:ERROR:图片生成出错") finally: if task_id in active_tasks: del active_tasks[task_id] return task async def send_image_to_client(websocket: WebSocket, image_path: str): """发送图片数据到客户端""" with open(image_path, 'rb') as f: image_data = f.read() print(f"Sending image to ESP32, size: {len(image_data)} bytes") # Send start marker await websocket.send_text(f"IMAGE_START:{len(image_data)}:{THUMB_SIZE}") # Send binary data directly chunk_size = 4096 # Increased chunk size for binary for i in range(0, len(image_data), chunk_size): chunk = image_data[i:i+chunk_size] await websocket.send_bytes(chunk) # Send end marker await websocket.send_text("IMAGE_END") print("Image sent to ESP32 (Binary)") def get_font_data(unicode_val): """从字体文件获取单个字符数据(带缓存)""" if unicode_val in font_cache: return font_cache[unicode_val] try: char = chr(unicode_val) gb_bytes = char.encode('gb2312') if len(gb_bytes) == 2: code = struct.unpack('>H', gb_bytes)[0] area = (code >> 8) - 0xA0 index = (code & 0xFF) - 0xA0 if area >= 1 and index >= 1: offset = ((area - 1) * 94 + (index - 1)) * 32 if font_data_buffer: if offset + 32 <= len(font_data_buffer): font_data = font_data_buffer[offset:offset+32] font_cache[unicode_val] = font_data return font_data else: # Fallback to file reading if buffer failed script_dir = os.path.dirname(os.path.abspath(__file__)) font_path = os.path.join(script_dir, FONT_FILE) if not os.path.exists(font_path): font_path = os.path.join(script_dir, "..", FONT_FILE) if not os.path.exists(font_path): font_path = FONT_FILE if os.path.exists(font_path): with open(font_path, "rb") as f: f.seek(offset) font_data = f.read(32) if len(font_data) == 32: font_cache[unicode_val] = font_data return font_data except: pass return None def send_font_batch_with_retry(websocket, code_list, retry_count=0): """批量发送字体数据(带重试机制)""" global font_request_queue success_codes = set() failed_codes = [] for code_str in code_list: if not code_str: continue try: unicode_val = int(code_str) font_data = get_font_data(unicode_val) if font_data: import binascii hex_data = binascii.hexlify(font_data).decode('utf-8') response = f"FONT_DATA:{code_str}:{hex_data}" asyncio.run_coroutine_threadsafe( websocket.send_text(response), asyncio.get_event_loop() ) success_codes.add(unicode_val) else: failed_codes.append(code_str) except Exception as e: print(f"Error processing font {code_str}: {e}") failed_codes.append(code_str) # 记录失败的请求用于重试 if failed_codes and retry_count < FONT_RETRY_MAX: req_key = f"retry_{retry_count}_{time.time()}" font_request_queue[req_key] = { 'codes': failed_codes, 'retry': retry_count + 1, 'timestamp': time.time() } return len(success_codes), failed_codes async def send_font_with_fragment(websocket, unicode_val): """使用二进制分片方式发送字体数据""" font_data = get_font_data(unicode_val) if not font_data: return False # 分片发送 total_size = len(font_data) chunk_size = FONT_CHUNK_SIZE for i in range(0, total_size, chunk_size): chunk = font_data[i:i+chunk_size] seq_num = i // chunk_size # 构造二进制消息头: 2字节序列号 + 2字节总片数 + 数据 header = struct.pack('> 8) - 0xA0 index = (code & 0xFF) - 0xA0 if area >= 1 and index >= 1: offset = ((area - 1) * 94 + (index - 1)) * 32 script_dir = os.path.dirname(os.path.abspath(__file__)) font_path = os.path.join(script_dir, FONT_FILE) if not os.path.exists(font_path): font_path = os.path.join(script_dir, "..", FONT_FILE) if os.path.exists(font_path): with open(font_path, "rb") as f: f.seek(offset) font_data = f.read(32) else: font_data = None else: font_data = None if font_data: import binascii hex_data = binascii.hexlify(font_data).decode('utf-8') response = f"FONT_DATA:{code_str}:{hex_data}" await websocket.send_text(response) except Exception as e: print(f"Error handling font request: {e}") class MyRecognitionCallback(RecognitionCallback): def __init__(self, websocket: WebSocket, loop: asyncio.AbstractEventLoop): self.websocket = websocket self.loop = loop self.final_text = "" # 保存最终识别结果 def on_open(self) -> None: print("ASR Session started") def on_close(self) -> None: print("ASR Session closed") def on_event(self, result: RecognitionResult) -> None: if result.get_sentence(): text = result.get_sentence()['text'] print(f"ASR Result: {text}") self.final_text = text # 保存识别结果 # 将识别结果发送回客户端 try: asyncio.run_coroutine_threadsafe( self.websocket.send_text(f"ASR:{text}"), self.loop ) except Exception as e: print(f"Failed to send ASR result to client: {e}") def process_chunk_32_to_16(chunk_bytes, gain=1.0): processed_chunk = bytearray() # Iterate 4 bytes at a time for i in range(0, len(chunk_bytes), 4): if i+3 < len(chunk_bytes): # 取 chunk[i+2] 和 chunk[i+3] 组成 16-bit signed int sample = struct.unpack_from(' 32767: sample = 32767 elif sample < -32768: sample = -32768 # 重新打包为 16-bit little-endian processed_chunk.extend(struct.pack('> 3) & 0x1F g6 = (g >> 2) & 0x3F b5 = (b >> 3) & 0x1F # 小端模式:低字节在前 rgb565 = (r5 << 11) | (g6 << 5) | b5 rgb565_data.extend(struct.pack(' 1 else "") else: await handle_font_request(websocket, text, "") except Exception as e: print(f"Font request error: {e}") await websocket.send_text("FONT_BATCH_END:0:0") # 计算偏移量 # GB2312 编码范围:0xA1A1 - 0xFEFE # 区码:高字节 - 0xA0 # 位码:低字节 - 0xA0 area = (code >> 8) - 0xA0 index = (code & 0xFF) - 0xA0 if area >= 1 and index >= 1: offset = ((area - 1) * 94 + (index - 1)) * 32 # 读取字体文件 # 注意:这里为了简单,每次都打开文件。如果并发高,应该缓存文件句柄或内容。 # 假设字体文件在当前目录或上级目录 # Prioritize finding the file in the script's directory script_dir = os.path.dirname(os.path.abspath(__file__)) font_path = os.path.join(script_dir, FONT_FILE) # Fallback: check one level up if not os.path.exists(font_path): font_path = os.path.join(script_dir, "..", FONT_FILE) # Fallback: check current working directory if not os.path.exists(font_path): font_path = FONT_FILE if os.path.exists(font_path): print(f"Reading font from: {font_path} (Offset: {offset})") with open(font_path, "rb") as f: f.seek(offset) font_data = f.read(32) if len(font_data) == 32: import binascii hex_data = binascii.hexlify(font_data).decode('utf-8') # Return the original requested code (unicode or hex) so client can map it back response = f"FONT_DATA:{target_code_str}:{hex_data}" # print(f"Sending Font Response: {response[:30]}...") await websocket.send_text(response) else: print(f"Error: Read {len(font_data)} bytes for font data (expected 32)") else: print(f"Font file not found: {font_path}") else: print(f"Invalid GB2312 code derived: {code:X} (Area: {area}, Index: {index})") except Exception as e: print(f"Error handling FONT request: {e}") elif "bytes" in message: # 接收音频数据并追加到缓冲区 data = message["bytes"] audio_buffer.extend(data) # 实时处理并发送给 ASR pcm_chunk = process_chunk_32_to_16(data, VOLUME_GAIN) processed_buffer.extend(pcm_chunk) if recognition: try: recognition.send_audio_frame(pcm_chunk) except Exception as e: print(f"Error sending audio frame to ASR: {e}") except WebSocketDisconnect: print("Client disconnected") if recognition: try: recognition.stop() except: pass except Exception as e: print(f"Error: {e}") if recognition: try: recognition.stop() except: pass if __name__ == "__main__": # 获取本机IP,方便ESP32连接 import socket hostname = socket.gethostname() local_ip = socket.gethostbyname(hostname) print(f"Server running on ws://{local_ip}:8000/ws/audio") uvicorn.run(app, host="0.0.0.0", port=8000)