| """ |
| Gemma 4 Any-to-Any — Flask API |
| |
| Architecture: |
| TARGET : google/gemma-4-e2b-it — full Gemma 4 E2B multimodal model |
| (text + audio + image, runs standalone) |
| DRAFTER : gsstec322/gemma — Gemma4Assistant MTP drafter |
| (passed as assistant_model for speculative |
| decoding — up to 3x speedup, same quality) |
| |
| The drafter CANNOT run standalone: it requires inputs_embeds + shared_kv_states |
| from the target model and must only be used as assistant_model in generate(). |
| |
| Endpoints |
| ───────── |
| GET /health → liveness probe |
| POST /audio-to-text → audio file → transcription / translation |
| POST /text-to-audio → text prompt → Gemma reply + WAV audio (base64) |
| """ |
|
|
| import base64 |
| import io |
| import os |
| import re |
| import tempfile |
| import textwrap |
| import traceback |
|
|
| import numpy as np |
| import soundfile as sf |
| import torch |
| from flask import Flask, jsonify, request |
| from transformers import AutoModelForCausalLM, AutoProcessor |
|
|
| |
| TARGET_MODEL_ID = "google/gemma-4-e2b-it" |
| ASSISTANT_MODEL_ID = "gsstec322/gemma" |
| DEVICE = "cuda" if torch.cuda.is_available() else "cpu" |
| DTYPE = torch.bfloat16 |
|
|
| |
| print(f"[init] Loading processor from {TARGET_MODEL_ID} …") |
| processor = AutoProcessor.from_pretrained(TARGET_MODEL_ID) |
|
|
| print(f"[init] Loading target model {TARGET_MODEL_ID} on {DEVICE} ({DTYPE}) …") |
| model = AutoModelForCausalLM.from_pretrained( |
| TARGET_MODEL_ID, |
| dtype=DTYPE, |
| device_map="auto", |
| ) |
| model.eval() |
| print("[init] Target model ready ✓") |
|
|
| print(f"[init] Loading assistant/drafter {ASSISTANT_MODEL_ID} …") |
| assistant_model = AutoModelForCausalLM.from_pretrained( |
| ASSISTANT_MODEL_ID, |
| dtype=DTYPE, |
| device_map="auto", |
| ) |
| assistant_model.eval() |
| print("[init] Assistant model ready ✓") |
|
|
| |
| app = Flask(__name__) |
|
|
|
|
| |
|
|
| def _apply_template(messages: list) -> str: |
| """Apply the processor's chat template, with a manual Gemma 4 fallback.""" |
| try: |
| return processor.apply_chat_template( |
| messages, tokenize=False, add_generation_prompt=True |
| ) |
| except Exception: |
| |
| text = "<bos>" |
| for msg in messages: |
| role = msg["role"] |
| content = msg["content"] if isinstance(msg["content"], str) else "" |
| text += f"<|turn>{role}\n{content}<turn|>" |
| text += "<|turn>model\n" |
| return text |
|
|
|
|
| def _parse_model_response(raw: str) -> str: |
| """Unwrap processor.parse_response to a plain string.""" |
| try: |
| parsed = processor.parse_response(raw) |
| if isinstance(parsed, dict): |
| return parsed.get("text", raw) |
| return str(parsed) |
| except Exception: |
| return re.sub(r"<[^>]+>", "", raw).strip() |
|
|
|
|
| def _run_text_only(messages: list, max_new_tokens: int = 512) -> str: |
| """Generate from text-only messages using speculative decoding.""" |
| text = _apply_template(messages) |
| inputs = processor(text=text, return_tensors="pt").to(DEVICE) |
| input_len = inputs["input_ids"].shape[-1] |
|
|
| with torch.inference_mode(): |
| outputs = model.generate( |
| **inputs, |
| assistant_model=assistant_model, |
| max_new_tokens=max_new_tokens, |
| do_sample=True, |
| temperature=1.0, |
| top_p=0.95, |
| top_k=64, |
| ) |
|
|
| raw = processor.decode(outputs[0][input_len:], skip_special_tokens=False) |
| return _parse_model_response(raw) |
|
|
|
|
| def _run_with_audio( |
| messages: list, audio_arrays: list, sample_rates: list, max_new_tokens: int = 512 |
| ) -> str: |
| """Generate from messages that include audio, using speculative decoding.""" |
| text = _apply_template(messages) |
|
|
| audios = [ |
| {"array": arr, "sampling_rate": sr} |
| for arr, sr in zip(audio_arrays, sample_rates) |
| ] |
|
|
| inputs = processor( |
| text=text, |
| audios=audios if audios else None, |
| return_tensors="pt", |
| ).to(DEVICE) |
| input_len = inputs["input_ids"].shape[-1] |
|
|
| with torch.inference_mode(): |
| outputs = model.generate( |
| **inputs, |
| assistant_model=assistant_model, |
| max_new_tokens=max_new_tokens, |
| do_sample=True, |
| temperature=1.0, |
| top_p=0.95, |
| top_k=64, |
| ) |
|
|
| raw = processor.decode(outputs[0][input_len:], skip_special_tokens=False) |
| return _parse_model_response(raw) |
|
|
|
|
| def _normalise_waveform(waveform: np.ndarray) -> np.ndarray: |
| """Convert any integer PCM to float32 in [-1, 1].""" |
| waveform = waveform.astype(np.float32) |
| peak = np.max(np.abs(waveform)) |
| if peak > 1.0: |
| waveform /= peak |
| return waveform |
|
|
|
|
| def _tts(text: str) -> tuple: |
| """ |
| Best-effort TTS: gTTS (online) → pyttsx3 (offline) → silence. |
| Returns (sample_rate, float32 waveform). |
| """ |
| |
| try: |
| from gtts import gTTS |
| tts = gTTS(text=text, lang="en") |
| buf = io.BytesIO() |
| tts.write_to_fp(buf) |
| buf.seek(0) |
| waveform, sr = sf.read(buf, dtype="float32") |
| return sr, waveform |
| except Exception: |
| pass |
|
|
| |
| try: |
| import pyttsx3 |
| engine = pyttsx3.init() |
| tmp = tempfile.mktemp(suffix=".wav") |
| engine.save_to_file(text, tmp) |
| engine.runAndWait() |
| waveform, sr = sf.read(tmp, dtype="float32") |
| os.remove(tmp) |
| return sr, waveform |
| except Exception: |
| pass |
|
|
| |
| sr = 22050 |
| return sr, np.zeros(sr, dtype=np.float32) |
|
|
|
|
| def _waveform_to_base64_wav(sr: int, waveform: np.ndarray) -> str: |
| """Encode a waveform as a base64 WAV string.""" |
| buf = io.BytesIO() |
| sf.write(buf, waveform, sr, format="WAV", subtype="PCM_16") |
| buf.seek(0) |
| return base64.b64encode(buf.read()).decode("utf-8") |
|
|
|
|
| |
|
|
| @app.get("/") |
| def index(): |
| """API index.""" |
| return jsonify({ |
| "target_model": TARGET_MODEL_ID, |
| "assistant_model": ASSISTANT_MODEL_ID, |
| "device": DEVICE, |
| "endpoints": { |
| "GET /health": "Liveness probe", |
| "POST /audio-to-text": "Upload audio file → transcription or translation (multipart/form-data)", |
| "POST /text-to-audio": "Send text prompt → Gemma reply + base64 WAV (application/json)", |
| }, |
| }) |
|
|
|
|
| @app.get("/health") |
| def health(): |
| """Liveness probe.""" |
| return jsonify({ |
| "status": "ok", |
| "target_model": TARGET_MODEL_ID, |
| "assistant_model": ASSISTANT_MODEL_ID, |
| "device": DEVICE, |
| }) |
|
|
|
|
| @app.post("/audio-to-text") |
| def audio_to_text(): |
| """ |
| Convert uploaded audio to text (transcription or translation). |
| |
| Multipart form fields |
| ───────────────────── |
| audio : audio file (wav, mp3, flac, ogg — max 30 s) |
| task : "transcribe" | "translate" (default: "transcribe") |
| source_language: e.g. "English" (default: "English") |
| target_language: e.g. "French" (default: "English", translate only) |
| max_new_tokens : int (default: 512) |
| |
| Response JSON → { "text": "..." } |
| """ |
| try: |
| if "audio" not in request.files: |
| return jsonify({"error": "No audio file. Send under the 'audio' key."}), 400 |
|
|
| audio_file = request.files["audio"] |
| task = request.form.get("task", "transcribe").lower() |
| source_language = request.form.get("source_language", "English") |
| target_language = request.form.get("target_language", "English") |
| max_new_tokens = int(request.form.get("max_new_tokens", 512)) |
|
|
| try: |
| buf = io.BytesIO(audio_file.read()) |
| waveform, sample_rate = sf.read(buf, dtype="float32") |
| except Exception as exc: |
| return jsonify({"error": f"Could not read audio file: {exc}"}), 422 |
|
|
| waveform = _normalise_waveform(waveform) |
|
|
| if task == "translate": |
| instruction = textwrap.dedent(f"""\ |
| Transcribe the following speech segment in {source_language}, \ |
| then translate it into {target_language}. |
| When formatting the answer, first output the transcription in \ |
| {source_language}, then one newline, then output '{target_language}: ', \ |
| then the translation.""") |
| else: |
| instruction = textwrap.dedent(f"""\ |
| Transcribe the following speech segment in {source_language}. |
| * Only output the transcription, with no newlines. |
| * Write numbers as digits (e.g. 3, not three).""") |
|
|
| messages = [ |
| { |
| "role": "user", |
| "content": [ |
| {"type": "text", "text": instruction}, |
| {"type": "audio", "array": waveform, "sampling_rate": sample_rate}, |
| ], |
| } |
| ] |
|
|
| result = _run_with_audio( |
| messages, |
| audio_arrays=[waveform], |
| sample_rates=[sample_rate], |
| max_new_tokens=max_new_tokens, |
| ) |
| return jsonify({"text": result}) |
|
|
| except Exception as exc: |
| return jsonify({"error": str(exc), "trace": traceback.format_exc()}), 500 |
|
|
|
|
| @app.post("/text-to-audio") |
| def text_to_audio(): |
| """ |
| Generate a Gemma 4 reply from a text prompt and return it as spoken audio. |
| |
| JSON body |
| ───────── |
| { |
| "text" : "What is the speed of light?", ← required |
| "system_prompt" : "You are a helpful assistant.", ← optional |
| "max_new_tokens": 256 ← optional (default 256) |
| } |
| |
| Response JSON |
| ───────────── |
| { |
| "reply_text" : "The speed of light is …", |
| "audio_wav" : "<base64-encoded WAV>", |
| "sample_rate": 22050 |
| } |
| """ |
| try: |
| body = request.get_json(force=True, silent=True) or {} |
|
|
| user_text = body.get("text", "").strip() |
| if not user_text: |
| return jsonify({"error": "'text' field is required and must not be empty."}), 400 |
|
|
| system_prompt = body.get("system_prompt", "You are a helpful assistant.") |
| max_new_tokens = int(body.get("max_new_tokens", 256)) |
|
|
| |
| |
| merged = f"{system_prompt}\n\n{user_text}" if system_prompt else user_text |
| messages = [{"role": "user", "content": merged}] |
|
|
| reply_text = _run_text_only(messages, max_new_tokens=max_new_tokens) |
|
|
| try: |
| sr, waveform = _tts(reply_text) |
| audio_b64 = _waveform_to_base64_wav(sr, waveform) |
| except Exception as tts_exc: |
| return jsonify({ |
| "reply_text" : reply_text, |
| "audio_wav" : None, |
| "sample_rate": None, |
| "tts_error" : str(tts_exc), |
| }) |
|
|
| return jsonify({ |
| "reply_text" : reply_text, |
| "audio_wav" : audio_b64, |
| "sample_rate": sr, |
| }) |
|
|
| except Exception as exc: |
| return jsonify({"error": str(exc), "trace": traceback.format_exc()}), 500 |
|
|
|
|
| |
| if __name__ == "__main__": |
| app.run(host="0.0.0.0", port=7860, debug=False) |
|
|