| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | # Adapted from Qwen3-ForcedAligner inference: |
| 4 | # https://github.com/QwenLM/Qwen3-ASR |
| 5 | |
| 6 | """Online forced alignment example using Qwen3-ForcedAligner-0.6B-hf. |
| 7 | |
| 8 | Forced alignment takes audio and reference text as input and produces |
| 9 | word-level timestamps. The model predicts a time bin at each <timestamp> |
| 10 | token position; multiplying by ``timestamp_segment_time`` gives milliseconds. |
| 11 | |
| 12 | This example uses STEP pooling to return logits only for <timestamp> tokens. |
| 13 | The ``step_tag_id`` value 151705 is this checkpoint's ``timestamp_token_id`` |
| 14 | from its config.json. |
| 15 | |
| 16 | Start the server with: |
| 17 | |
| 18 | vllm serve Qwen/Qwen3-ForcedAligner-0.6B-hf \ |
| 19 | --runner pooling \ |
| 20 | --convert classify \ |
| 21 | --pooler-config \ |
| 22 | '{"tok_pooling_type":"STEP","step_tag_id":151705,"use_activation":false}' \ |
| 23 | --enforce-eager \ |
| 24 | --trust-request-chat-template |
| 25 | |
| 26 | Then run: |
| 27 | |
| 28 | python forced_alignment_online.py |
| 29 | """ |
| 30 | |
| 31 | import argparse |
| 32 | import json |
| 33 | import mimetypes |
| 34 | import wave |
| 35 | from io import BytesIO |
| 36 | from pathlib import Path |
| 37 | from typing import Any |
| 38 | |
| 39 | import numpy as np |
| 40 | import pybase64 as base64 |
| 41 | import requests |
| 42 | import torch |
| 43 | from huggingface_hub import hf_hub_download |
| 44 | |
| 45 | RAW_CONTENT_CHAT_TEMPLATE = "{{ messages[0]['content'] }}" |
| 46 | |
| 47 | |
| 48 | def build_prompt(words: list[str]) -> str: |
| 49 | """Build the forced alignment prompt from a word list. |
| 50 | |
| 51 | Format: <|audio_start|><|audio_pad|><|audio_end|> |
| 52 | word1<timestamp><timestamp>word2<timestamp><timestamp>... |
| 53 | """ |
| 54 | body = "<timestamp><timestamp>".join(words) + "<timestamp><timestamp>" |
| 55 | return f"<|audio_start|><|audio_pad|><|audio_end|>{body}" |
| 56 | |
| 57 | |
| 58 | def encode_audio_data_uri(audio_path: Path) -> str: |
| 59 | mime_type = mimetypes.guess_type(audio_path)[0] or "audio/wav" |
| 60 | audio_base64 = base64.b64encode(audio_path.read_bytes()).decode("utf-8") |
| 61 | return f"data:{mime_type};base64,{audio_base64}" |
| 62 | |
| 63 | |
| 64 | def encode_silent_wav_data_uri(sample_rate: int = 16000, duration_s: int = 5) -> str: |
| 65 | audio = np.zeros(sample_rate * duration_s, dtype=np.int16) |
| 66 | |
| 67 | with BytesIO() as audio_buffer: |
| 68 | with wave.open(audio_buffer, "wb") as wav_file: |
| 69 | wav_file.setnchannels(1) |
| 70 | wav_file.setsampwidth(np.dtype(np.int16).itemsize) |
| 71 | wav_file.setframerate(sample_rate) |
| 72 | wav_file.writeframes(audio.tobytes()) |
| 73 | |
| 74 | audio_base64 = base64.b64encode(audio_buffer.getvalue()).decode("utf-8") |
| 75 | |
| 76 | return f"data:audio/wav;base64,{audio_base64}" |
| 77 | |
| 78 | |
| 79 | def build_payload(model: str, prompt: str, audio_uri: str) -> dict[str, Any]: |
| 80 | return { |
| 81 | "model": model, |
| 82 | "messages": [ |
| 83 | { |
| 84 | "role": "user", |
| 85 | "content": [ |
| 86 | {"type": "text", "text": prompt}, |
| 87 | {"type": "audio_url", "audio_url": {"url": audio_uri}}, |
| 88 | ], |
| 89 | } |
| 90 | ], |
| 91 | "task": "token_classify", |
| 92 | "chat_template": RAW_CONTENT_CHAT_TEMPLATE, |
| 93 | } |
| 94 | |
| 95 | |
| 96 | def post_http_request(payload: dict[str, Any], api_url: str) -> requests.Response: |
| 97 | headers = {"User-Agent": "Test Client"} |
| 98 | return requests.post(api_url, headers=headers, json=payload) |
| 99 | |
| 100 | |
| 101 | def parse_response(response: requests.Response) -> dict[str, Any]: |
| 102 | try: |
| 103 | result = response.json() |
| 104 | except ValueError as exc: |
| 105 | raise RuntimeError( |
| 106 | f"Server returned non-JSON response: {response.text}" |
| 107 | ) from exc |
| 108 | |
| 109 | if response.status_code != 200 or "data" not in result: |
| 110 | raise RuntimeError(f"Server error ({response.status_code}): {result}") |
| 111 | |
| 112 | return result |
| 113 | |
| 114 | |
| 115 | def load_timestamp_segment_time(model: str) -> float: |
| 116 | model_path = Path(model) |
| 117 | config_path = ( |
| 118 | model_path / "config.json" |
| 119 | if model_path.exists() |
| 120 | else Path(hf_hub_download(repo_id=model, filename="config.json")) |
| 121 | ) |
| 122 | |
| 123 | with config_path.open() as f: |
| 124 | config = json.load(f) |
| 125 | |
| 126 | return config["timestamp_segment_time"] |
| 127 | |
| 128 | |
| 129 | def parse_args(): |
| 130 | parser = argparse.ArgumentParser() |
| 131 | parser.add_argument("--host", type=str, default="localhost") |
| 132 | parser.add_argument("--port", type=int, default=8000) |
| 133 | parser.add_argument( |
| 134 | "--model", |
| 135 | type=str, |
| 136 | default="Qwen/Qwen3-ForcedAligner-0.6B-hf", |
| 137 | ) |
| 138 | parser.add_argument( |
| 139 | "--audio-path", |
| 140 | type=Path, |
| 141 | default=None, |
| 142 | help="Optional audio file. Defaults to a 5-second silent WAV.", |
| 143 | ) |
| 144 | parser.add_argument( |
| 145 | "--words", |
| 146 | nargs="+", |
| 147 | default=["Hello", "world"], |
| 148 | help="Reference words to align against the audio.", |
| 149 | ) |
| 150 | return parser.parse_args() |
| 151 | |
| 152 | |
| 153 | def main(args): |
| 154 | api_url = f"http://{args.host}:{args.port}/pooling" |
| 155 | prompt = build_prompt(args.words) |
| 156 | audio_uri = ( |
| 157 | encode_audio_data_uri(args.audio_path) |
| 158 | if args.audio_path |
| 159 | else encode_silent_wav_data_uri() |
| 160 | ) |
| 161 | payload = build_payload(args.model, prompt, audio_uri) |
| 162 | |
| 163 | pooling_response = post_http_request(payload=payload, api_url=api_url) |
| 164 | result = parse_response(pooling_response) |
| 165 | |
| 166 | timestamp_segment_time = load_timestamp_segment_time(args.model) |
| 167 | output = result["data"][0] |
| 168 | logits = torch.tensor(output["data"]) |
| 169 | predictions = logits.argmax(dim=-1) |
| 170 | |
| 171 | expected_timestamps = len(args.words) * 2 |
| 172 | if len(predictions) != expected_timestamps: |
| 173 | raise RuntimeError( |
| 174 | f"Expected {expected_timestamps} timestamp predictions, " |
| 175 | f"but received {len(predictions)}. Check that the server was started " |
| 176 | "with STEP pooling." |
| 177 | ) |
| 178 | |
| 179 | ts_predictions = [ |
| 180 | prediction.item() * timestamp_segment_time for prediction in predictions |
| 181 | ] |
| 182 | |
| 183 | for i, word in enumerate(args.words): |
| 184 | start_ms = ts_predictions[i * 2] |
| 185 | end_ms = ts_predictions[i * 2 + 1] |
| 186 | print(f"{word:15s} {start_ms / 1000:.3f}s - {end_ms / 1000:.3f}s") |
| 187 | |
| 188 | |
| 189 | if __name__ == "__main__": |
| 190 | args = parse_args() |
| 191 | main(args) |