SearingShot commited on
Commit
bb5dd0a
Β·
1 Parent(s): 18dd411

Deploy SignApp main app

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.glb filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY src/ src/
9
+ RUN mkdir -p uploads
10
+
11
+ EXPOSE 7860
12
+
13
+ CMD ["uvicorn", "src.sign_app.api:app", "--host", "0.0.0.0", "--port", "7860"]
14
+
README.md CHANGED
@@ -1,10 +1,25 @@
1
  ---
2
  title: SignApp
3
- emoji: πŸ“Š
4
- colorFrom: green
5
- colorTo: pink
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: SignApp
3
+ emoji: 🀟
4
+ colorFrom: indigo
5
+ colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # SignApp
12
+
13
+ Main SignApp UI/API deployment. This Space should stay lightweight and call the
14
+ separate model Spaces for Whisper and disfluency removal.
15
+
16
+ Required Space secrets:
17
+
18
+ - `MONGODB_URI`
19
+ - `WHISPER_API_URL`
20
+ - `DISFLUENCY_API_URL`
21
+
22
+ Optional Space secrets:
23
+
24
+ - `HF_TOKEN` for private/protected model Spaces or better public Space rate limits
25
+ - `REMOTE_API_TIMEOUT`, default `300`
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.104.0
2
+ uvicorn>=0.24.0
3
+ python-multipart>=0.0.6
4
+ python-dotenv>=1.0.0
5
+ nltk>=3.8.1
6
+ requests>=2.31.0
7
+ pymongo>=4.6.0
8
+
src/sign_app/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SignApp - Audio processing pipeline for sign language recognition.
3
+
4
+ This module provides functionality for:
5
+ - Audio transcription using Whisper
6
+ - Disfluency removal from transcriptions
7
+ - Further processing for sign language recognition
8
+ """
9
+
10
+ __version__ = "0.1.0"
src/sign_app/api.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import shutil
3
+ from contextlib import asynccontextmanager
4
+ from pathlib import Path
5
+
6
+ import requests
7
+ from dotenv import load_dotenv
8
+ from fastapi import FastAPI, File, HTTPException, UploadFile
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import FileResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+ from pydantic import BaseModel
13
+ from pymongo import MongoClient
14
+
15
+ load_dotenv()
16
+
17
+
18
+ MONGODB_URI = os.getenv("MONGODB_URI", "mongodb://localhost:27017/")
19
+ WHISPER_MODEL_SIZE = os.getenv("WHISPER_MODEL", "small")
20
+
21
+ WHISPER_API_URL = os.getenv("WHISPER_API_URL", "").strip().rstrip("/")
22
+ DISFLUENCY_API_URL = os.getenv("DISFLUENCY_API_URL", "").strip().rstrip("/")
23
+ REMOTE_API_TIMEOUT = int(os.getenv("REMOTE_API_TIMEOUT", "300"))
24
+ HF_TOKEN = os.getenv("HF_TOKEN", "").strip()
25
+
26
+ UPLOAD_DIR = Path("uploads")
27
+ UPLOAD_DIR.mkdir(exist_ok=True)
28
+
29
+ UI_DIR = Path(__file__).parent / "ui"
30
+
31
+ client = MongoClient(MONGODB_URI)
32
+ db = client["SignApp"]
33
+ sign_rules_col = db["sign_rules"]
34
+ fingerspell_col = db["fingerspelling"]
35
+
36
+ _whisper_model = None
37
+ _disfluency_fn = None
38
+
39
+
40
+ def _auth_headers() -> dict[str, str]:
41
+ if not HF_TOKEN:
42
+ return {}
43
+ return {"Authorization": f"Bearer {HF_TOKEN}"}
44
+
45
+
46
+ def get_whisper():
47
+ global _whisper_model
48
+ if _whisper_model is None:
49
+ import whisper
50
+
51
+ _whisper_model = whisper.load_model(WHISPER_MODEL_SIZE)
52
+ return _whisper_model
53
+
54
+
55
+ def get_disfluency_fn():
56
+ global _disfluency_fn
57
+ if _disfluency_fn is None:
58
+ from .disfluency.inference import remove_disfluency
59
+
60
+ _disfluency_fn = remove_disfluency
61
+ return _disfluency_fn
62
+
63
+
64
+ def transcribe_audio(file_path: Path) -> dict:
65
+ if WHISPER_API_URL:
66
+ with file_path.open("rb") as audio_file:
67
+ response = requests.post(
68
+ f"{WHISPER_API_URL}/transcribe/",
69
+ headers=_auth_headers(),
70
+ files={"file": (file_path.name, audio_file, "audio/webm")},
71
+ timeout=REMOTE_API_TIMEOUT,
72
+ )
73
+ response.raise_for_status()
74
+ data = response.json()
75
+ return {
76
+ "text": data.get("text", ""),
77
+ "language": data.get("language", "en"),
78
+ }
79
+
80
+ whisper_model = get_whisper()
81
+ result = whisper_model.transcribe(str(file_path), language="en")
82
+ return {
83
+ "text": result["text"],
84
+ "language": result["language"],
85
+ }
86
+
87
+
88
+ def clean_disfluency(text: str) -> str:
89
+ if DISFLUENCY_API_URL:
90
+ response = requests.post(
91
+ f"{DISFLUENCY_API_URL}/clean/",
92
+ headers=_auth_headers(),
93
+ json={"text": text},
94
+ timeout=REMOTE_API_TIMEOUT,
95
+ )
96
+ response.raise_for_status()
97
+ data = response.json()
98
+ return data.get("cleaned_text", "").strip()
99
+
100
+ return get_disfluency_fn()(text)
101
+
102
+
103
+ @asynccontextmanager
104
+ async def lifespan(app: FastAPI):
105
+ if not WHISPER_API_URL:
106
+ print("Loading local Whisper model on startup...")
107
+ get_whisper()
108
+ else:
109
+ print(f"Using remote Whisper API: {WHISPER_API_URL}")
110
+
111
+ if not DISFLUENCY_API_URL:
112
+ print("Loading local disfluency model on startup...")
113
+ get_disfluency_fn()
114
+ else:
115
+ print(f"Using remote disfluency API: {DISFLUENCY_API_URL}")
116
+
117
+ print("SignApp startup complete.")
118
+ yield
119
+
120
+
121
+ app = FastAPI(title="SignApp", version="0.1.0", lifespan=lifespan)
122
+
123
+ app.add_middleware(
124
+ CORSMiddleware,
125
+ allow_origins=["*"],
126
+ allow_credentials=True,
127
+ allow_methods=["*"],
128
+ allow_headers=["*"],
129
+ )
130
+
131
+ from .sign_language_text.gloss_converter import convert_to_sign_gloss
132
+
133
+
134
+ class TextInput(BaseModel):
135
+ text: str
136
+
137
+
138
+ def build_sign_sequence(gloss_tokens: list[str]) -> list[dict]:
139
+ """Look up each gloss token in MongoDB sign_rules, fall back to fingerspelling."""
140
+ sign_sequence = []
141
+
142
+ for word in gloss_tokens:
143
+ rule = sign_rules_col.find_one({"sign": word})
144
+
145
+ if rule:
146
+ sign_sequence.append(
147
+ {
148
+ "type": "sign",
149
+ "gloss": word,
150
+ "handshape": rule["handshape"],
151
+ "location": rule["location"],
152
+ "movement": rule["movement"],
153
+ "expression": rule.get("expression", "neutral"),
154
+ }
155
+ )
156
+ else:
157
+ for letter in word:
158
+ finger = fingerspell_col.find_one({"letter": letter.upper()})
159
+ if finger:
160
+ sign_sequence.append(
161
+ {
162
+ "type": "fingerspell",
163
+ "letter": letter.upper(),
164
+ "handshape": finger["handshape"],
165
+ "location": "neutral_space",
166
+ "movement": finger.get("movement") or "none",
167
+ }
168
+ )
169
+
170
+ return sign_sequence
171
+
172
+
173
+ def text_pipeline(text: str) -> dict:
174
+ cleaned_text = clean_disfluency(text)
175
+ sign_friendly_text = convert_to_sign_gloss(cleaned_text)
176
+ sign_sequence = build_sign_sequence(sign_friendly_text)
177
+
178
+ return {
179
+ "cleaned_transcription": cleaned_text,
180
+ "sign_friendly_text": sign_friendly_text,
181
+ "sign_sequence": sign_sequence,
182
+ }
183
+
184
+
185
+ @app.get("/health")
186
+ def health():
187
+ return {
188
+ "status": "ok",
189
+ "whisper": "remote" if WHISPER_API_URL else "local",
190
+ "disfluency": "remote" if DISFLUENCY_API_URL else "local",
191
+ }
192
+
193
+
194
+ @app.post("/voice-to-text/")
195
+ def voice_to_text_endpoint(file: UploadFile = File(...)):
196
+ """Full pipeline: audio -> transcription -> gloss -> sign sequence."""
197
+ file_path = UPLOAD_DIR / (file.filename or "recording.webm")
198
+
199
+ try:
200
+ with file_path.open("wb") as audio_file:
201
+ shutil.copyfileobj(file.file, audio_file)
202
+
203
+ transcription_result = transcribe_audio(file_path)
204
+ transcription = transcription_result["text"]
205
+ language = transcription_result["language"]
206
+
207
+ result = text_pipeline(transcription)
208
+ return {
209
+ "language": language,
210
+ "raw_transcription": transcription,
211
+ **result,
212
+ }
213
+
214
+ except requests.RequestException as exc:
215
+ raise HTTPException(status_code=502, detail=f"Remote model service failed: {exc}") from exc
216
+ except Exception as exc:
217
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
218
+ finally:
219
+ if file_path.exists():
220
+ file_path.unlink()
221
+
222
+
223
+ @app.post("/text-to-sign/")
224
+ def text_to_sign_endpoint(body: TextInput):
225
+ """Text-only pipeline: text -> gloss -> sign sequence."""
226
+ text = body.text.strip()
227
+ if not text:
228
+ raise HTTPException(status_code=400, detail="Text is empty")
229
+
230
+ try:
231
+ return text_pipeline(text)
232
+ except requests.RequestException as exc:
233
+ raise HTTPException(status_code=502, detail=f"Remote model service failed: {exc}") from exc
234
+ except Exception as exc:
235
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
236
+
237
+
238
+ @app.get("/")
239
+ def serve_ui():
240
+ return FileResponse(UI_DIR / "index.html")
241
+
242
+
243
+ app.mount("/", StaticFiles(directory=str(UI_DIR)), name="ui")
src/sign_app/audio/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Audio processing module using Whisper."""
src/sign_app/disfluency/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Disfluency removal module for cleaning speech transcriptions."""
src/sign_app/disfluency/inference.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from transformers import T5Tokenizer, T5ForConditionalGeneration
3
+
4
+ MODEL_PATH = "./speechCleaner_t5_model"
5
+
6
+ # Load tokenizer & model
7
+ tokenizer = T5Tokenizer.from_pretrained(MODEL_PATH)
8
+ model = T5ForConditionalGeneration.from_pretrained(MODEL_PATH)
9
+
10
+
11
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
12
+ model.to(device)
13
+ model.eval()
14
+
15
+
16
+ def remove_disfluency(text: str) -> str:
17
+ inputs = tokenizer(
18
+ "clean speech: " + text,
19
+ return_tensors="pt",
20
+ truncation=True,
21
+ padding=True
22
+ ).to(device)
23
+
24
+ with torch.no_grad():
25
+ outputs = model.generate(
26
+ **inputs,
27
+ max_length=256,
28
+ num_beams=4,
29
+ early_stopping=True
30
+ )
31
+ cleaned_text = tokenizer.decode(outputs[0], skip_special_tokens=True)
32
+ return cleaned_text.strip()
33
+
34
+ # Test the disfluency removal on some example sentences
35
+ if __name__ == "__main__":
36
+ text = "I uh want to go to the store"
37
+ print(remove_disfluency(text))
src/sign_app/disfluency/training.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datasets import load_dataset
2
+ from transformers import T5Tokenizer, T5ForConditionalGeneration, Seq2SeqTrainer, Seq2SeqTrainingArguments, DataCollatorForSeq2Seq
3
+ import mlflow
4
+ import evaluate
5
+ import nltk
6
+ nltk.download('punkt')
7
+ bleu = evaluate.load("bleu")
8
+ rouge = evaluate.load("rouge")
9
+
10
+ base_model = "t5-base" # You can choose a larger model like "t5-base" or "t5-large" if you have the resources
11
+
12
+ tokenizer = T5Tokenizer.from_pretrained(base_model)
13
+ transformer_model = T5ForConditionalGeneration.from_pretrained(base_model)
14
+
15
+ switchboard_dataset = load_dataset("amaai-lab/DisfluencySpeech")
16
+
17
+ def keep_only_text_columns(example):
18
+ return {
19
+ "input_text": example["transcript_a"],
20
+ "target_text": example["transcript_c"]
21
+ }
22
+
23
+ dataset = switchboard_dataset.map(
24
+ keep_only_text_columns,
25
+ remove_columns=switchboard_dataset["train"].column_names
26
+ )
27
+
28
+ def is_valid(example):
29
+ return (
30
+ example["input_text"] is not None
31
+ and example["target_text"] is not None
32
+ and example["input_text"].strip() != ""
33
+ and example["target_text"].strip() != ""
34
+ )
35
+
36
+ dataset = dataset.filter(is_valid)
37
+
38
+ encoding_max_length = 256
39
+ decoding_max_length = 256
40
+
41
+ def tokenize(sentences):
42
+ inputs = ["clean speech: " + text for text in sentences["input_text"]]
43
+
44
+ model_inputs = tokenizer(inputs, max_length=encoding_max_length, truncation=True, padding="max_length")
45
+ labels = tokenizer(
46
+ sentences["target_text"],
47
+ max_length=decoding_max_length,
48
+ truncation=True,
49
+ padding="max_length"
50
+ )
51
+ model_inputs["labels"] = labels["input_ids"]
52
+ return model_inputs
53
+
54
+ tokenized_dataset = dataset.map(
55
+ tokenize,batched=True,remove_columns=dataset["train"].column_names
56
+ )
57
+
58
+ data_collator = DataCollatorForSeq2Seq(tokenizer, model=transformer_model)
59
+
60
+ def compute_metrics(eval_pred):
61
+ predictions, labels = eval_pred
62
+ decoded_preds = tokenizer.batch_decode(predictions, skip_special_tokens=True)
63
+ labels = [[label if label != -100 else tokenizer.pad_token_id for label in l] for l in labels]
64
+ decoded_labels = tokenizer.batch_decode(labels, skip_special_tokens=True)
65
+
66
+ decoded_preds = [pred.strip() for pred in decoded_preds]
67
+ decoded_labels = [label.strip() for label in decoded_labels]
68
+
69
+ bleu_result = bleu.compute(predictions=decoded_preds, references=decoded_labels)
70
+ rouge_result = rouge.compute(predictions=decoded_preds, references=decoded_labels)
71
+
72
+ return {
73
+ "bleu": bleu_result["bleu"],
74
+ "rouge1": rouge_result["rouge1"],
75
+ "rouge2": rouge_result["rouge2"],
76
+ "rougeL": rouge_result["rougeL"]
77
+ }
78
+
79
+ training_args = Seq2SeqTrainingArguments(
80
+ output_dir="./speechCleaner_t5_model",
81
+ eval_strategy="epoch",
82
+ save_strategy="epoch",
83
+ learning_rate=3e-5,
84
+ per_device_train_batch_size=8,
85
+ per_device_eval_batch_size=8,
86
+ num_train_epochs=5,
87
+ weight_decay=0.01,
88
+ logging_steps=100,
89
+ save_total_limit=2,
90
+ fp16=True, # Set to True if you have a compatible GPU
91
+ report_to="mlflow",
92
+ predict_with_generate=True
93
+ )
94
+
95
+ trainer = Seq2SeqTrainer(
96
+ model=transformer_model,
97
+ args=training_args,
98
+ train_dataset=tokenized_dataset["train"],
99
+ eval_dataset=tokenized_dataset["validation"],
100
+ data_collator=data_collator,
101
+ compute_metrics=compute_metrics
102
+ )
103
+
104
+ mlflow.set_tracking_uri("file:./mlruns")
105
+ mlflow.set_experiment("speechCleaner_t5_model")
106
+ with mlflow.start_run():
107
+ trainer.train()
108
+ trainer.save_model("./SpeechCleaner_t5_model")
109
+ tokenizer.save_pretrained("./SpeechCleaner_t5_model")
110
+
111
+
112
+ # Test the trained model on some example sentences
113
+ def clean_text(text: str) -> str:
114
+ inputs = tokenizer("clean speech: " + text, return_tensors="pt", truncation=True).input_ids.to(transformer_model.device)
115
+ outputs = transformer_model.generate(inputs, max_length=decoding_max_length, num_beams=4, early_stopping=True)
116
+ cleaned_text = tokenizer.decode(outputs[0], skip_special_tokens=True)
117
+ return cleaned_text
118
+
119
+ print(clean_text("Yeah uh I I don't work but I used to work when I had two children"))
120
+ print(clean_text("I want to go to the store um to buy some groceries"))
121
+ print(clean_text("So uh the meeting is scheduled for uh next Monday at 10 am"))
src/sign_app/seed_signs.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Seed MongoDB with ASL sign rules, fingerspelling, handshapes, locations, and movements.
3
+
4
+ Usage:
5
+ uv run python -m src.sign_app.seed_signs
6
+ """
7
+
8
+ import os
9
+ from dotenv import load_dotenv
10
+ from pymongo import MongoClient
11
+
12
+ load_dotenv()
13
+
14
+ MONGODB_URI = os.getenv("MONGODB_URI", "mongodb://localhost:27017/")
15
+
16
+ client = MongoClient(MONGODB_URI)
17
+ db = client["SignApp"]
18
+
19
+
20
+ # ═══════════════════════════════════════════════════════════
21
+ # SIGN RULES β€” 60+ common ASL signs
22
+ # ═══════════════════════════════════════════════════════════
23
+
24
+ SIGN_RULES = [
25
+ # Greetings & Social
26
+ {"sign": "HELLO", "handshape": "B", "location": "forehead", "movement": "wave"},
27
+ {"sign": "BYE", "handshape": "OPEN", "location": "neutral_space", "movement": "wave"},
28
+ {"sign": "THANK-YOU", "handshape": "FLAT", "location": "chin", "movement": "forward"},
29
+ {"sign": "PLEASE", "handshape": "FLAT", "location": "chest", "movement": "circle_clockwise"},
30
+ {"sign": "SORRY", "handshape": "S", "location": "chest", "movement": "circle_clockwise"},
31
+ {"sign": "YES", "handshape": "S", "location": "neutral_space", "movement": "nod"},
32
+ {"sign": "NO", "handshape": "POINT","location": "neutral_space", "movement": "side_to_side"},
33
+ {"sign": "OK", "handshape": "O", "location": "neutral_space", "movement": "none"},
34
+ {"sign": "EXCUSE", "handshape": "FLAT", "location": "chest", "movement": "forward"},
35
+
36
+ # Pronouns
37
+ {"sign": "I", "handshape": "POINT","location": "chest", "movement": "touch"},
38
+ {"sign": "YOU", "handshape": "POINT","location": "neutral_space", "movement": "forward"},
39
+ {"sign": "HE", "handshape": "POINT","location": "side", "movement": "forward"},
40
+ {"sign": "SHE", "handshape": "POINT","location": "side", "movement": "forward"},
41
+ {"sign": "WE", "handshape": "POINT","location": "shoulder", "movement": "circle_clockwise"},
42
+ {"sign": "THEY", "handshape": "POINT","location": "neutral_space", "movement": "side_to_side"},
43
+ {"sign": "MY", "handshape": "FLAT", "location": "chest", "movement": "tap"},
44
+ {"sign": "YOUR", "handshape": "FLAT", "location": "neutral_space", "movement": "forward"},
45
+
46
+ # Common Verbs
47
+ {"sign": "KNOW", "handshape": "B", "location": "temple", "movement": "tap"},
48
+ {"sign": "THINK", "handshape": "POINT","location": "forehead", "movement": "tap"},
49
+ {"sign": "WANT", "handshape": "CLAW", "location": "neutral_space", "movement": "forward"},
50
+ {"sign": "NEED", "handshape": "POINT","location": "neutral_space", "movement": "nod"},
51
+ {"sign": "LIKE", "handshape": "OPEN", "location": "chest", "movement": "forward"},
52
+ {"sign": "LOVE", "handshape": "FIST", "location": "chest", "movement": "tap"},
53
+ {"sign": "HELP", "handshape": "A", "location": "neutral_space", "movement": "up"},
54
+ {"sign": "SEE", "handshape": "V", "location": "nose", "movement": "forward"},
55
+ {"sign": "LOOK", "handshape": "V", "location": "nose", "movement": "forward"},
56
+ {"sign": "HEAR", "handshape": "POINT","location": "ear", "movement": "tap"},
57
+ {"sign": "LISTEN", "handshape": "C", "location": "ear", "movement": "tap"},
58
+ {"sign": "SAY", "handshape": "POINT","location": "chin", "movement": "forward"},
59
+ {"sign": "TELL", "handshape": "POINT","location": "chin", "movement": "forward"},
60
+ {"sign": "ASK", "handshape": "POINT","location": "neutral_space", "movement": "forward"},
61
+ {"sign": "GO", "handshape": "POINT","location": "neutral_space", "movement": "forward"},
62
+ {"sign": "COME", "handshape": "POINT","location": "neutral_space", "movement": "pull_in"},
63
+ {"sign": "EAT", "handshape": "FLAT", "location": "mouth", "movement": "tap"},
64
+ {"sign": "DRINK", "handshape": "C", "location": "mouth", "movement": "tap"},
65
+ {"sign": "WORK", "handshape": "S", "location": "neutral_space", "movement": "tap"},
66
+ {"sign": "LEARN", "handshape": "FLAT", "location": "forehead", "movement": "tap"},
67
+ {"sign": "TEACH", "handshape": "FLAT", "location": "forehead", "movement": "forward"},
68
+ {"sign": "UNDERSTAND", "handshape": "S", "location": "temple", "movement": "tap"},
69
+ {"sign": "FEEL", "handshape": "OPEN", "location": "chest", "movement": "up"},
70
+ {"sign": "GIVE", "handshape": "FLAT", "location": "neutral_space", "movement": "forward"},
71
+ {"sign": "TAKE", "handshape": "CLAW", "location": "neutral_space", "movement": "forward"},
72
+ {"sign": "MAKE", "handshape": "S", "location": "neutral_space", "movement": "twist"},
73
+ {"sign": "GET", "handshape": "CLAW", "location": "neutral_space", "movement": "forward"},
74
+ {"sign": "HAVE", "handshape": "B", "location": "chest", "movement": "tap"},
75
+ {"sign": "WAIT", "handshape": "OPEN", "location": "neutral_space", "movement": "none"},
76
+ {"sign": "STOP", "handshape": "FLAT", "location": "neutral_space", "movement": "down"},
77
+ {"sign": "START", "handshape": "POINT","location": "neutral_space", "movement": "twist"},
78
+ {"sign": "FINISH", "handshape": "OPEN", "location": "neutral_space", "movement": "down"},
79
+ {"sign": "TRY", "handshape": "S", "location": "neutral_space", "movement": "forward"},
80
+ {"sign": "WALK", "handshape": "FLAT", "location": "neutral_space", "movement": "forward"},
81
+ {"sign": "SIT", "handshape": "H", "location": "neutral_space", "movement": "down"},
82
+ {"sign": "STAND", "handshape": "V", "location": "neutral_space", "movement": "none"},
83
+ {"sign": "SIGN", "handshape": "POINT","location": "neutral_space", "movement": "circle_clockwise"},
84
+ {"sign": "WATCH", "handshape": "V", "location": "nose", "movement": "forward"},
85
+ {"sign": "OPEN", "handshape": "B", "location": "neutral_space", "movement": "side_to_side"},
86
+ {"sign": "CLOSE", "handshape": "B", "location": "neutral_space", "movement": "forward"},
87
+ {"sign": "BUY", "handshape": "FLAT", "location": "neutral_space", "movement": "forward"},
88
+ {"sign": "READ", "handshape": "V", "location": "neutral_space", "movement": "down"},
89
+ {"sign": "WRITE", "handshape": "POINT","location": "neutral_space", "movement": "down"},
90
+
91
+ # Question Words
92
+ {"sign": "WHAT", "handshape": "OPEN", "location": "neutral_space", "movement": "side_to_side"},
93
+ {"sign": "WHERE", "handshape": "POINT","location": "neutral_space", "movement": "side_to_side"},
94
+ {"sign": "WHEN", "handshape": "POINT","location": "neutral_space", "movement": "circle_clockwise"},
95
+ {"sign": "WHY", "handshape": "Y", "location": "forehead", "movement": "forward"},
96
+ {"sign": "HOW", "handshape": "FIST", "location": "neutral_space", "movement": "twist"},
97
+ {"sign": "WHO", "handshape": "L", "location": "chin", "movement": "tap"},
98
+
99
+ # Nouns
100
+ {"sign": "PERSON", "handshape": "FLAT", "location": "neutral_space", "movement": "down"},
101
+ {"sign": "PEOPLE", "handshape": "P", "location": "neutral_space", "movement": "circle_clockwise"},
102
+ {"sign": "FRIEND", "handshape": "X", "location": "neutral_space", "movement": "twist"},
103
+ {"sign": "FAMILY", "handshape": "F", "location": "neutral_space", "movement": "circle_clockwise"},
104
+ {"sign": "MOTHER", "handshape": "OPEN", "location": "chin", "movement": "tap"},
105
+ {"sign": "FATHER", "handshape": "OPEN", "location": "forehead", "movement": "tap"},
106
+ {"sign": "NAME", "handshape": "H", "location": "neutral_space", "movement": "tap"},
107
+ {"sign": "HOME", "handshape": "FLAT", "location": "chin", "movement": "tap"},
108
+ {"sign": "SCHOOL", "handshape": "FLAT", "location": "neutral_space", "movement": "double_tap"},
109
+ {"sign": "FOOD", "handshape": "FLAT", "location": "mouth", "movement": "tap"},
110
+ {"sign": "WATER", "handshape": "W", "location": "chin", "movement": "tap"},
111
+ {"sign": "MONEY", "handshape": "FLAT", "location": "neutral_space", "movement": "tap"},
112
+ {"sign": "TIME", "handshape": "POINT","location": "neutral_space", "movement": "tap"},
113
+ {"sign": "DAY", "handshape": "D", "location": "neutral_space", "movement": "down"},
114
+ {"sign": "TODAY", "handshape": "FLAT", "location": "neutral_space", "movement": "down"},
115
+ {"sign": "TOMORROW", "handshape": "A", "location": "chin", "movement": "forward"},
116
+ {"sign": "YESTERDAY", "handshape": "A", "location": "chin", "movement": "tap"},
117
+ {"sign": "MORNING", "handshape": "FLAT", "location": "neutral_space", "movement": "up"},
118
+ {"sign": "NIGHT", "handshape": "FLAT", "location": "neutral_space", "movement": "down"},
119
+ {"sign": "STORE", "handshape": "FLAT", "location": "neutral_space", "movement": "twist"},
120
+
121
+ # Adjectives
122
+ {"sign": "GOOD", "handshape": "FLAT", "location": "chin", "movement": "forward"},
123
+ {"sign": "BAD", "handshape": "FLAT", "location": "chin", "movement": "down"},
124
+ {"sign": "HAPPY", "handshape": "FLAT", "location": "chest", "movement": "circle_clockwise"},
125
+ {"sign": "SAD", "handshape": "OPEN", "location": "chin", "movement": "down"},
126
+ {"sign": "BIG", "handshape": "OPEN", "location": "neutral_space", "movement": "side_to_side"},
127
+ {"sign": "SMALL", "handshape": "FLAT", "location": "neutral_space", "movement": "tap"},
128
+ {"sign": "BEAUTIFUL", "handshape": "OPEN", "location": "chin", "movement": "circle_clockwise"},
129
+ {"sign": "EASY", "handshape": "FLAT", "location": "neutral_space", "movement": "up"},
130
+ {"sign": "HARD", "handshape": "V", "location": "neutral_space", "movement": "tap"},
131
+ {"sign": "HOT", "handshape": "CLAW", "location": "mouth", "movement": "forward"},
132
+ {"sign": "COLD", "handshape": "S", "location": "neutral_space", "movement": "side_to_side"},
133
+ {"sign": "NEW", "handshape": "FLAT", "location": "neutral_space", "movement": "forward"},
134
+ {"sign": "OLD", "handshape": "C", "location": "chin", "movement": "down"},
135
+ {"sign": "NICE", "handshape": "FLAT", "location": "neutral_space", "movement": "forward"},
136
+ {"sign": "FINE", "handshape": "OPEN", "location": "chest", "movement": "tap"},
137
+ {"sign": "DIFFERENT", "handshape": "POINT","location": "neutral_space", "movement": "side_to_side"},
138
+ {"sign": "SAME", "handshape": "POINT","location": "neutral_space", "movement": "tap"},
139
+ {"sign": "TRUE", "handshape": "POINT","location": "chin", "movement": "forward"},
140
+ {"sign": "WRONG", "handshape": "Y", "location": "chin", "movement": "tap"},
141
+ {"sign": "IMPORTANT", "handshape": "F", "location": "neutral_space", "movement": "up"},
142
+ {"sign": "READY", "handshape": "R", "location": "neutral_space", "movement": "side_to_side"},
143
+ {"sign": "DEAF", "handshape": "POINT","location": "ear", "movement": "forward"},
144
+ {"sign": "HUNGRY", "handshape": "C", "location": "chest", "movement": "down"},
145
+
146
+ # Adverbs / Misc
147
+ {"sign": "NOT", "handshape": "A", "location": "chin", "movement": "forward"},
148
+ {"sign": "NEVER", "handshape": "B", "location": "neutral_space", "movement": "down"},
149
+ {"sign": "ALWAYS", "handshape": "POINT","location": "neutral_space", "movement": "circle_clockwise"},
150
+ {"sign": "SOMETIMES", "handshape": "FLAT", "location": "neutral_space", "movement": "tap"},
151
+ {"sign": "AGAIN", "handshape": "FLAT", "location": "neutral_space", "movement": "tap"},
152
+ {"sign": "MORE", "handshape": "FLAT", "location": "neutral_space", "movement": "tap"},
153
+ {"sign": "ALSO", "handshape": "POINT","location": "neutral_space", "movement": "tap"},
154
+ {"sign": "NOW", "handshape": "FLAT", "location": "neutral_space", "movement": "down"},
155
+ {"sign": "LATER", "handshape": "L", "location": "neutral_space", "movement": "forward"},
156
+ {"sign": "HERE", "handshape": "FLAT", "location": "neutral_space", "movement": "circle_clockwise"},
157
+ {"sign": "THERE", "handshape": "POINT","location": "neutral_space", "movement": "forward"},
158
+ {"sign": "MAYBE", "handshape": "FLAT", "location": "neutral_space", "movement": "side_to_side"},
159
+ {"sign": "BECAUSE", "handshape": "POINT","location": "forehead", "movement": "forward"},
160
+ {"sign": "BUT", "handshape": "POINT","location": "neutral_space", "movement": "side_to_side"},
161
+ {"sign": "AND", "handshape": "OPEN", "location": "neutral_space", "movement": "forward"},
162
+ {"sign": "WITH", "handshape": "A", "location": "neutral_space", "movement": "tap"},
163
+ {"sign": "FOR", "handshape": "POINT","location": "forehead", "movement": "forward"},
164
+ {"sign": "FROM", "handshape": "X", "location": "neutral_space", "movement": "forward"},
165
+ {"sign": "ABOUT", "handshape": "POINT","location": "neutral_space", "movement": "circle_clockwise"},
166
+ {"sign": "MANY", "handshape": "S", "location": "neutral_space", "movement": "forward"},
167
+ {"sign": "ALL", "handshape": "OPEN", "location": "neutral_space", "movement": "circle_clockwise"},
168
+ {"sign": "EVERY", "handshape": "A", "location": "neutral_space", "movement": "down"},
169
+ {"sign": "ENOUGH", "handshape": "FLAT", "location": "neutral_space", "movement": "forward"},
170
+ ]
171
+
172
+
173
+ # ═══════════════════════════════════════════════════════════
174
+ # FINGERSPELLING
175
+ # ═══════════════════════════════════════════════════════════
176
+
177
+ FINGERSPELLING = [
178
+ {"letter": "A", "handshape": "A", "movement": "none"},
179
+ {"letter": "B", "handshape": "B", "movement": "none"},
180
+ {"letter": "C", "handshape": "C", "movement": "none"},
181
+ {"letter": "D", "handshape": "D", "movement": "none"},
182
+ {"letter": "E", "handshape": "E", "movement": "none"},
183
+ {"letter": "F", "handshape": "F", "movement": "none"},
184
+ {"letter": "G", "handshape": "G", "movement": "none"},
185
+ {"letter": "H", "handshape": "H", "movement": "none"},
186
+ {"letter": "I", "handshape": "I", "movement": "none"},
187
+ {"letter": "J", "handshape": "J", "movement": "circle_clockwise"},
188
+ {"letter": "K", "handshape": "K", "movement": "none"},
189
+ {"letter": "L", "handshape": "L", "movement": "none"},
190
+ {"letter": "M", "handshape": "M", "movement": "none"},
191
+ {"letter": "N", "handshape": "N", "movement": "none"},
192
+ {"letter": "O", "handshape": "O", "movement": "none"},
193
+ {"letter": "P", "handshape": "P", "movement": "none"},
194
+ {"letter": "Q", "handshape": "Q", "movement": "none"},
195
+ {"letter": "R", "handshape": "R", "movement": "none"},
196
+ {"letter": "S", "handshape": "S", "movement": "none"},
197
+ {"letter": "T", "handshape": "T", "movement": "none"},
198
+ {"letter": "U", "handshape": "U", "movement": "none"},
199
+ {"letter": "V", "handshape": "V", "movement": "none"},
200
+ {"letter": "W", "handshape": "W", "movement": "none"},
201
+ {"letter": "X", "handshape": "X", "movement": "none"},
202
+ {"letter": "Y", "handshape": "Y", "movement": "none"},
203
+ {"letter": "Z", "handshape": "Z", "movement": "forward"},
204
+ ]
205
+
206
+
207
+ # ═══════════════════════════════════════════════════════════
208
+ # HANDSHAPES
209
+ # ═══════════════════════════════════════════════════════════
210
+
211
+ HANDSHAPES = [
212
+ {"name": "A"},
213
+ {"name": "B"},
214
+ {"name": "C"},
215
+ {"name": "D"},
216
+ {"name": "E"},
217
+ {"name": "F"},
218
+ {"name": "G"},
219
+ {"name": "H"},
220
+ {"name": "I"},
221
+ {"name": "K"},
222
+ {"name": "L"},
223
+ {"name": "O"},
224
+ {"name": "P"},
225
+ {"name": "R"},
226
+ {"name": "S"},
227
+ {"name": "V"},
228
+ {"name": "W"},
229
+ {"name": "X"},
230
+ {"name": "Y"},
231
+ {"name": "OPEN"},
232
+ {"name": "FIST"},
233
+ {"name": "POINT"},
234
+ {"name": "FLAT"},
235
+ {"name": "CLAW"},
236
+ ]
237
+
238
+
239
+ # ═══════════════════════════════════════════════════════════
240
+ # LOCATIONS
241
+ # ═══════════════════════════════════════════════════════════
242
+
243
+ LOCATIONS = [
244
+ {"name": "neutral_space"},
245
+ {"name": "chest"},
246
+ {"name": "chin"},
247
+ {"name": "mouth"},
248
+ {"name": "nose"},
249
+ {"name": "forehead"},
250
+ {"name": "temple"},
251
+ {"name": "side"},
252
+ {"name": "shoulder"},
253
+ {"name": "ear"},
254
+ {"name": "waist"},
255
+ ]
256
+
257
+
258
+ # ═══════════════════════════════════════════════════════════
259
+ # MOVEMENTS
260
+ # ═══════════════════════════════════════════════════════════
261
+
262
+ MOVEMENTS = [
263
+ {"name": "none"},
264
+ {"name": "tap"},
265
+ {"name": "double_tap"},
266
+ {"name": "circle_clockwise"},
267
+ {"name": "circle_counterclockwise"},
268
+ {"name": "forward"},
269
+ {"name": "down"},
270
+ {"name": "up"},
271
+ {"name": "side_to_side"},
272
+ {"name": "nod"},
273
+ {"name": "twist"},
274
+ {"name": "wave"},
275
+ ]
276
+
277
+
278
+ def seed():
279
+ """Seed all collections. Uses upsert to avoid duplicates."""
280
+ print("Seeding SignApp database...")
281
+
282
+ # Sign rules
283
+ col = db["sign_rules"]
284
+ for rule in SIGN_RULES:
285
+ col.update_one({"sign": rule["sign"]}, {"$set": rule}, upsert=True)
286
+ print(f" βœ“ sign_rules: {len(SIGN_RULES)} signs")
287
+
288
+ # Fingerspelling
289
+ col = db["fingerspelling"]
290
+ for fs in FINGERSPELLING:
291
+ col.update_one({"letter": fs["letter"]}, {"$set": fs}, upsert=True)
292
+ print(f" βœ“ fingerspelling: {len(FINGERSPELLING)} letters")
293
+
294
+ # Handshapes
295
+ col = db["handshapes"]
296
+ for hs in HANDSHAPES:
297
+ col.update_one({"name": hs["name"]}, {"$set": hs}, upsert=True)
298
+ print(f" βœ“ handshapes: {len(HANDSHAPES)} shapes")
299
+
300
+ # Locations
301
+ col = db["locations"]
302
+ for loc in LOCATIONS:
303
+ col.update_one({"name": loc["name"]}, {"$set": loc}, upsert=True)
304
+ print(f" βœ“ locations: {len(LOCATIONS)} locations")
305
+
306
+ # Movements
307
+ col = db["movements"]
308
+ for mov in MOVEMENTS:
309
+ col.update_one({"name": mov["name"]}, {"$set": mov}, upsert=True)
310
+ print(f" βœ“ movements: {len(MOVEMENTS)} movements")
311
+
312
+ print("\nβœ… Database seeded successfully!")
313
+
314
+
315
+ if __name__ == "__main__":
316
+ seed()
src/sign_app/sign_language_text/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Conversion of text to sign-friendly format."""
src/sign_app/sign_language_text/gloss_converter.py ADDED
@@ -0,0 +1,501 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hybrid English β†’ ASL Gloss Converter
3
+
4
+ Combines three strategies for accurate gloss generation:
5
+ 1. Rule-based grammar transforms (drop articles/copulas, reorder)
6
+ 2. NLTK WordNet lemmatizer for verb/noun normalization
7
+ 3. Comprehensive gloss lookup dictionary for idioms & common phrases
8
+ """
9
+
10
+ import re
11
+ import nltk
12
+ from nltk.stem import WordNetLemmatizer
13
+
14
+ # Download required NLTK data (only once)
15
+ try:
16
+ nltk.data.find("corpora/wordnet")
17
+ except LookupError:
18
+ nltk.download("wordnet", quiet=True)
19
+ try:
20
+ nltk.data.find("taggers/averaged_perceptron_tagger_eng")
21
+ except LookupError:
22
+ nltk.download("averaged_perceptron_tagger_eng", quiet=True)
23
+ try:
24
+ nltk.data.find("corpora/omw-1.4")
25
+ except LookupError:
26
+ nltk.download("omw-1.4", quiet=True)
27
+
28
+ _lemmatizer = WordNetLemmatizer()
29
+
30
+ # ── Words to drop (ASL omits these) ────────────────────────────────
31
+ ARTICLES = {"a", "an", "the"}
32
+ COPULAS = {"is", "am", "are", "was", "were", "be", "been", "being"}
33
+ AUXILIARIES = {"do", "does", "did", "will", "would", "shall", "should",
34
+ "can", "could", "may", "might", "must", "has", "have", "had"}
35
+ PREPOSITIONS_DROP = {"to", "of"} # commonly dropped in ASL
36
+ FILLER_WORDS = {"just", "really", "very", "so", "um", "uh", "like",
37
+ "well", "actually", "basically", "literally"}
38
+
39
+ DROP_WORDS = ARTICLES | COPULAS | FILLER_WORDS
40
+
41
+ # ── Phrase-level gloss dictionary (multi-word β†’ single sign) ───────
42
+ PHRASE_GLOSSARY: dict[str, str] = {
43
+ "thank you": "THANK-YOU",
44
+ "thanks": "THANK-YOU",
45
+ "how are you": "HOW YOU",
46
+ "what's up": "WHAT-UP",
47
+ "good morning": "GOOD MORNING",
48
+ "good night": "GOOD NIGHT",
49
+ "good afternoon": "GOOD AFTERNOON",
50
+ "excuse me": "EXCUSE",
51
+ "i'm sorry": "SORRY",
52
+ "i am sorry": "SORRY",
53
+ "a lot": "MANY",
54
+ "don't": "NOT",
55
+ "doesn't": "NOT",
56
+ "didn't": "NOT",
57
+ "can't": "CAN NOT",
58
+ "cannot": "CAN NOT",
59
+ "won't": "WILL NOT",
60
+ "wouldn't": "WILL NOT",
61
+ "shouldn't": "SHOULD NOT",
62
+ "couldn't": "CAN NOT",
63
+ "isn't": "NOT",
64
+ "aren't": "NOT",
65
+ "wasn't": "NOT",
66
+ "weren't": "NOT",
67
+ "i'm": "I",
68
+ "i am": "I",
69
+ "you're": "YOU",
70
+ "you are": "YOU",
71
+ "he's": "HE",
72
+ "she's": "SHE",
73
+ "it's": "IT",
74
+ "we're": "WE",
75
+ "they're": "THEY",
76
+ "there is": "HAVE",
77
+ "there are": "HAVE",
78
+ "right now": "NOW",
79
+ "a little": "LITTLE",
80
+ "a bit": "LITTLE",
81
+ "of course": "OF-COURSE",
82
+ "no problem": "NO-PROBLEM",
83
+ "long time": "LONG-TIME",
84
+ "how much": "HOW-MUCH",
85
+ "how many": "HOW-MANY",
86
+ }
87
+
88
+ # ── Word-level synonym/mapping dictionary ──────────────────────────
89
+ WORD_GLOSSARY: dict[str, str] = {
90
+ # Greetings
91
+ "hello": "HELLO",
92
+ "hi": "HELLO",
93
+ "hey": "HELLO",
94
+ "goodbye": "BYE",
95
+ "bye": "BYE",
96
+
97
+ # Pronouns (pass-through but normalize)
98
+ "i": "I",
99
+ "me": "I",
100
+ "my": "MY",
101
+ "mine": "MY",
102
+ "you": "YOU",
103
+ "your": "YOUR",
104
+ "yours": "YOUR",
105
+ "he": "HE",
106
+ "him": "HE",
107
+ "his": "HIS",
108
+ "she": "SHE",
109
+ "her": "SHE",
110
+ "hers": "HER",
111
+ "it": "IT",
112
+ "its": "IT",
113
+ "we": "WE",
114
+ "us": "WE",
115
+ "our": "OUR",
116
+ "they": "THEY",
117
+ "them": "THEY",
118
+ "their": "THEIR",
119
+
120
+ # Common verbs (map to ASL base forms)
121
+ "want": "WANT",
122
+ "wants": "WANT",
123
+ "wanted": "WANT",
124
+ "wanting": "WANT",
125
+ "need": "NEED",
126
+ "needs": "NEED",
127
+ "needed": "NEED",
128
+ "like": "LIKE",
129
+ "likes": "LIKE",
130
+ "liked": "LIKE",
131
+ "love": "LOVE",
132
+ "loves": "LOVE",
133
+ "loved": "LOVE",
134
+ "know": "KNOW",
135
+ "knows": "KNOW",
136
+ "knew": "KNOW",
137
+ "known": "KNOW",
138
+ "think": "THINK",
139
+ "thinks": "THINK",
140
+ "thought": "THINK",
141
+ "see": "SEE",
142
+ "sees": "SEE",
143
+ "saw": "SEE",
144
+ "seen": "SEE",
145
+ "help": "HELP",
146
+ "helps": "HELP",
147
+ "helped": "HELP",
148
+ "go": "GO",
149
+ "goes": "GO",
150
+ "going": "GO",
151
+ "went": "GO",
152
+ "gone": "GO",
153
+ "come": "COME",
154
+ "comes": "COME",
155
+ "came": "COME",
156
+ "eat": "EAT",
157
+ "eats": "EAT",
158
+ "ate": "EAT",
159
+ "eaten": "EAT",
160
+ "drink": "DRINK",
161
+ "drinks": "DRINK",
162
+ "drank": "DRINK",
163
+ "work": "WORK",
164
+ "works": "WORK",
165
+ "worked": "WORK",
166
+ "working": "WORK",
167
+ "live": "LIVE",
168
+ "lives": "LIVE",
169
+ "lived": "LIVE",
170
+ "feel": "FEEL",
171
+ "feels": "FEEL",
172
+ "felt": "FEEL",
173
+ "say": "SAY",
174
+ "says": "SAY",
175
+ "said": "SAY",
176
+ "tell": "TELL",
177
+ "tells": "TELL",
178
+ "told": "TELL",
179
+ "ask": "ASK",
180
+ "asks": "ASK",
181
+ "asked": "ASK",
182
+ "give": "GIVE",
183
+ "gives": "GIVE",
184
+ "gave": "GIVE",
185
+ "given": "GIVE",
186
+ "take": "TAKE",
187
+ "takes": "TAKE",
188
+ "took": "TAKE",
189
+ "taken": "TAKE",
190
+ "make": "MAKE",
191
+ "makes": "MAKE",
192
+ "made": "MAKE",
193
+ "get": "GET",
194
+ "gets": "GET",
195
+ "got": "GET",
196
+ "wait": "WAIT",
197
+ "waits": "WAIT",
198
+ "waited": "WAIT",
199
+ "learn": "LEARN",
200
+ "learns": "LEARN",
201
+ "learned": "LEARN",
202
+ "teach": "TEACH",
203
+ "teaches": "TEACH",
204
+ "taught": "TEACH",
205
+ "understand": "UNDERSTAND",
206
+ "understands": "UNDERSTAND",
207
+ "understood": "UNDERSTAND",
208
+ "finish": "FINISH",
209
+ "finished": "FINISH",
210
+ "start": "START",
211
+ "started": "START",
212
+ "stop": "STOP",
213
+ "stopped": "STOP",
214
+ "try": "TRY",
215
+ "tries": "TRY",
216
+ "tried": "TRY",
217
+ "call": "CALL",
218
+ "called": "CALL",
219
+ "play": "PLAY",
220
+ "played": "PLAY",
221
+ "run": "RUN",
222
+ "ran": "RUN",
223
+ "walk": "WALK",
224
+ "walked": "WALK",
225
+ "sit": "SIT",
226
+ "sat": "SIT",
227
+ "stand": "STAND",
228
+ "stood": "STAND",
229
+ "open": "OPEN",
230
+ "opened": "OPEN",
231
+ "close": "CLOSE",
232
+ "closed": "CLOSE",
233
+ "buy": "BUY",
234
+ "bought": "BUY",
235
+ "bring": "BRING",
236
+ "brought": "BRING",
237
+ "read": "READ",
238
+ "write": "WRITE",
239
+ "wrote": "WRITE",
240
+ "written": "WRITE",
241
+ "speak": "SPEAK",
242
+ "spoke": "SPEAK",
243
+ "sign": "SIGN",
244
+ "signed": "SIGN",
245
+ "watch": "WATCH",
246
+ "look": "LOOK",
247
+ "looked": "LOOK",
248
+ "listen": "LISTEN",
249
+
250
+ # Question words
251
+ "what": "WHAT",
252
+ "where": "WHERE",
253
+ "when": "WHEN",
254
+ "why": "WHY",
255
+ "how": "HOW",
256
+ "who": "WHO",
257
+ "which": "WHICH",
258
+
259
+ # Common nouns
260
+ "person": "PERSON",
261
+ "people": "PEOPLE",
262
+ "man": "MAN",
263
+ "woman": "WOMAN",
264
+ "boy": "BOY",
265
+ "girl": "GIRL",
266
+ "child": "CHILD",
267
+ "children": "CHILD",
268
+ "baby": "BABY",
269
+ "friend": "FRIEND",
270
+ "friends": "FRIEND",
271
+ "family": "FAMILY",
272
+ "mother": "MOTHER",
273
+ "mom": "MOTHER",
274
+ "father": "FATHER",
275
+ "dad": "FATHER",
276
+ "brother": "BROTHER",
277
+ "sister": "SISTER",
278
+ "dog": "DOG",
279
+ "cat": "CAT",
280
+ "house": "HOUSE",
281
+ "home": "HOME",
282
+ "school": "SCHOOL",
283
+ "food": "FOOD",
284
+ "water": "WATER",
285
+ "car": "CAR",
286
+ "book": "BOOK",
287
+ "phone": "PHONE",
288
+ "name": "NAME",
289
+ "day": "DAY",
290
+ "today": "TODAY",
291
+ "tomorrow": "TOMORROW",
292
+ "yesterday": "YESTERDAY",
293
+ "morning": "MORNING",
294
+ "night": "NIGHT",
295
+ "time": "TIME",
296
+ "world": "WORLD",
297
+ "year": "YEAR",
298
+ "money": "MONEY",
299
+ "job": "WORK",
300
+ "store": "STORE",
301
+ "door": "DOOR",
302
+ "place": "PLACE",
303
+ "city": "CITY",
304
+ "country": "COUNTRY",
305
+ "weather": "WEATHER",
306
+
307
+ # Adjectives
308
+ "good": "GOOD",
309
+ "bad": "BAD",
310
+ "nice": "NICE",
311
+ "happy": "HAPPY",
312
+ "sad": "SAD",
313
+ "angry": "ANGRY",
314
+ "tired": "TIRED",
315
+ "sick": "SICK",
316
+ "big": "BIG",
317
+ "small": "SMALL",
318
+ "little": "SMALL",
319
+ "new": "NEW",
320
+ "old": "OLD",
321
+ "young": "YOUNG",
322
+ "beautiful": "BEAUTIFUL",
323
+ "pretty": "BEAUTIFUL",
324
+ "ugly": "UGLY",
325
+ "easy": "EASY",
326
+ "hard": "HARD",
327
+ "difficult": "HARD",
328
+ "fast": "FAST",
329
+ "quick": "FAST",
330
+ "slow": "SLOW",
331
+ "hot": "HOT",
332
+ "cold": "COLD",
333
+ "hungry": "HUNGRY",
334
+ "thirsty": "THIRSTY",
335
+ "important": "IMPORTANT",
336
+ "right": "RIGHT",
337
+ "wrong": "WRONG",
338
+ "same": "SAME",
339
+ "different": "DIFFERENT",
340
+ "ready": "READY",
341
+ "true": "TRUE",
342
+ "correct": "TRUE",
343
+ "deaf": "DEAF",
344
+
345
+ # Adverbs / misc
346
+ "yes": "YES",
347
+ "no": "NO",
348
+ "not": "NOT",
349
+ "never": "NEVER",
350
+ "always": "ALWAYS",
351
+ "sometimes": "SOMETIMES",
352
+ "often": "OFTEN",
353
+ "again": "AGAIN",
354
+ "more": "MORE",
355
+ "also": "ALSO",
356
+ "too": "ALSO",
357
+ "please": "PLEASE",
358
+ "sorry": "SORRY",
359
+ "now": "NOW",
360
+ "later": "LATER",
361
+ "here": "HERE",
362
+ "there": "THERE",
363
+ "maybe": "MAYBE",
364
+ "ok": "OK",
365
+ "okay": "OK",
366
+ "sure": "YES",
367
+ "fine": "FINE",
368
+ "together": "TOGETHER",
369
+ "before": "BEFORE",
370
+ "after": "AFTER",
371
+ "because": "BECAUSE",
372
+ "but": "BUT",
373
+ "and": "AND",
374
+ "or": "OR",
375
+ "if": "IF",
376
+ "then": "THEN",
377
+ "with": "WITH",
378
+ "for": "FOR",
379
+ "from": "FROM",
380
+ "in": "IN",
381
+ "at": "AT",
382
+ "on": "ON",
383
+ "about": "ABOUT",
384
+ "every": "EVERY",
385
+ "all": "ALL",
386
+ "many": "MANY",
387
+ "some": "SOME",
388
+ "enough": "ENOUGH",
389
+ "each": "EACH",
390
+ }
391
+
392
+
393
+ def _pos_tag_to_wordnet(tag: str) -> str:
394
+ """Map NLTK POS tag to WordNet POS for the lemmatizer."""
395
+ if tag.startswith("V"):
396
+ return "v"
397
+ if tag.startswith("N"):
398
+ return "n"
399
+ if tag.startswith("J"):
400
+ return "a"
401
+ if tag.startswith("R"):
402
+ return "r"
403
+ return "n" # default noun
404
+
405
+
406
+ def convert_to_sign_gloss(text: str) -> list[str]:
407
+ """
408
+ Convert English text to ASL gloss tokens.
409
+
410
+ Pipeline:
411
+ 1. Lowercase & strip punctuation
412
+ 2. Match multi-word phrases from PHRASE_GLOSSARY
413
+ 3. For remaining words: lookup in WORD_GLOSSARY
414
+ 4. If not in glossary: lemmatize with NLTK and try again
415
+ 5. If still unknown: pass through as uppercase (will be fingerspelled)
416
+ 6. Drop filler/grammar words that ASL omits
417
+ """
418
+ # Normalize
419
+ text = text.lower().strip()
420
+ text = re.sub(r"[''']", "'", text) # normalize apostrophes
421
+ text = re.sub(r"[^\w\s'-]", " ", text) # strip punctuation except apostrophes/hyphens
422
+ text = re.sub(r"\s+", " ", text).strip()
423
+
424
+ # ── Phase 1: Multi-word phrase matching ────────────────────────
425
+ # Replace known phrases with their gloss (longest match first)
426
+ for phrase in sorted(PHRASE_GLOSSARY, key=len, reverse=True):
427
+ if phrase in text:
428
+ replacement = PHRASE_GLOSSARY[phrase]
429
+ text = text.replace(phrase, f" {replacement} ")
430
+ text = re.sub(r"\s+", " ", text).strip()
431
+
432
+ tokens = text.split()
433
+
434
+ # ── Phase 2: Word-level processing ─────────────────────────────
435
+ # POS-tag for better lemmatization
436
+ tagged = nltk.pos_tag(tokens)
437
+
438
+ gloss_tokens: list[str] = []
439
+
440
+ for word, tag in tagged:
441
+ # Already converted by phrase matching (uppercase)
442
+ if word.isupper() or "-" in word and word == word.upper():
443
+ # Split multi-token gloss results
444
+ for part in word.split():
445
+ gloss_tokens.append(part)
446
+ continue
447
+
448
+ # Skip drop words
449
+ if word in DROP_WORDS:
450
+ continue
451
+
452
+ # Skip auxiliaries (ASL mostly drops these)
453
+ if word in AUXILIARIES:
454
+ continue
455
+
456
+ # Skip prepositions that ASL drops
457
+ if word in PREPOSITIONS_DROP:
458
+ continue
459
+
460
+ # Direct glossary lookup
461
+ if word in WORD_GLOSSARY:
462
+ gloss_tokens.append(WORD_GLOSSARY[word])
463
+ continue
464
+
465
+ # Try lemmatization then glossary
466
+ wn_pos = _pos_tag_to_wordnet(tag)
467
+ lemma = _lemmatizer.lemmatize(word, pos=wn_pos)
468
+
469
+ if lemma in WORD_GLOSSARY:
470
+ gloss_tokens.append(WORD_GLOSSARY[lemma])
471
+ continue
472
+
473
+ # Try verb lemmatization specifically (catches "going" -> "go")
474
+ verb_lemma = _lemmatizer.lemmatize(word, pos="v")
475
+ if verb_lemma in WORD_GLOSSARY:
476
+ gloss_tokens.append(WORD_GLOSSARY[verb_lemma])
477
+ continue
478
+
479
+ # Unknown word β€” pass through uppercase (will be fingerspelled)
480
+ gloss_tokens.append(word.upper())
481
+
482
+ return gloss_tokens
483
+
484
+
485
+ # ── Test ───────────────────────────────────────────────────────────
486
+ if __name__ == "__main__":
487
+ tests = [
488
+ "Hello, I know you are a good person.",
489
+ "I want to go to the store",
490
+ "She is going to the park later",
491
+ "Can you help me with this?",
492
+ "The weather is nice today",
493
+ "Thank you for your help",
494
+ "I don't understand",
495
+ "What is your name?",
496
+ "How are you?",
497
+ "I'm sorry, I can't come tomorrow",
498
+ ]
499
+ for t in tests:
500
+ result = convert_to_sign_gloss(t)
501
+ print(f"{t:45s} β†’ {' '.join(result)}")
src/sign_app/sign_language_text/inference.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import torch
3
+ from transformers import T5ForConditionalGeneration, T5Tokenizer
4
+
5
+ MODEL_PATH = "./sign_language_converter_model"
6
+
7
+ # Load tokenizer & model
8
+ tokenizer = T5Tokenizer.from_pretrained(MODEL_PATH)
9
+ model = T5ForConditionalGeneration.from_pretrained(MODEL_PATH)
10
+
11
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
12
+ model.to(device)
13
+ model.eval()
14
+
15
+ def convert_to_sign_friendly(text: str) -> list[str]:
16
+ inputs = tokenizer(
17
+ "convert to sign-friendly: " + text,
18
+ return_tensors="pt",
19
+ truncation=True,
20
+ padding=True
21
+ ).to(device)
22
+
23
+ with torch.no_grad():
24
+ outputs = model.generate(
25
+ **inputs,
26
+ max_length=256,
27
+ num_beams=4,
28
+ early_stopping=True
29
+ )
30
+ sign_friendly_text = tokenizer.decode(outputs[0], skip_special_tokens=True)
31
+ sign_friendly_text = clean_gloss(sign_friendly_text)
32
+
33
+ return sign_friendly_text
34
+
35
+ def clean_gloss(gloss: str) -> list[str]:
36
+ words = gloss.split()
37
+
38
+ cleaned = []
39
+ for w in words:
40
+
41
+ w = re.sub(r"[,.!]", "", w)
42
+
43
+ if w.startswith("X-"):
44
+ w = w.replace("X-", "")
45
+ if w.startswith("DESC-"):
46
+ w = w.replace("DESC-", "")
47
+
48
+ cleaned.append(w)
49
+
50
+ return cleaned
51
+
52
+ # Test the conversion on some example sentences
53
+ if __name__ == "__main__":
54
+ text = "I want to go to the store"
55
+ print(convert_to_sign_friendly(text))
56
+ print(convert_to_sign_friendly("She is going to the park later"))
57
+ print(convert_to_sign_friendly("Can you help me with this?"))
58
+ print(convert_to_sign_friendly("The weather is nice today"))
src/sign_app/sign_language_text/training.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datasets import load_dataset
2
+ from transformers import T5Tokenizer, T5ForConditionalGeneration, Seq2SeqTrainer, Seq2SeqTrainingArguments, DataCollatorForSeq2Seq
3
+ import mlflow
4
+ import evaluate
5
+ import nltk
6
+
7
+ nltk.download('punkt')
8
+ bleu = evaluate.load("bleu")
9
+ rouge = evaluate.load("rouge")
10
+
11
+ base_model = "t5-small"
12
+
13
+ tokenizer = T5Tokenizer.from_pretrained(base_model)
14
+ transformer_model = T5ForConditionalGeneration.from_pretrained(base_model)
15
+
16
+ sign_language_conversion_dataset = load_dataset("achrafothman/aslg_pc12")
17
+
18
+ sign_language_conversion_dataset = load_dataset("achrafothman/aslg_pc12")
19
+
20
+ # Split 90% train, 10% validation
21
+ sign_language_conversion_dataset = sign_language_conversion_dataset["train"].train_test_split(test_size=0.1)
22
+
23
+
24
+ def sign_friendly_mapping(example):
25
+ return {
26
+ "input_text": example["text"],
27
+ "target_text": example["gloss"]
28
+ }
29
+
30
+ Dataset = sign_language_conversion_dataset.map(
31
+ sign_friendly_mapping,
32
+ remove_columns=sign_language_conversion_dataset["train"].column_names
33
+ )
34
+
35
+ def is_valid(example):
36
+ return (
37
+ example["input_text"] is not None
38
+ and example["target_text"] is not None
39
+ and example["input_text"].strip() != ""
40
+ and example["target_text"].strip() != ""
41
+ )
42
+
43
+ Dataset = Dataset.filter(is_valid)
44
+
45
+ max_input_length = 256
46
+ max_target_length = 256
47
+
48
+ def tokenize_function(examples):
49
+ inputs = ["convert to sign-friendly: " + text for text in examples["input_text"]]
50
+ model_inputs = tokenizer(inputs, max_length=max_input_length, truncation=True, padding="max_length")
51
+ labels = tokenizer(
52
+ examples["target_text"],
53
+ max_length=max_target_length,
54
+ truncation=True,
55
+ padding="max_length"
56
+ )
57
+ model_inputs["labels"] = labels["input_ids"]
58
+ return model_inputs
59
+
60
+ tokenized_dataset = Dataset.map(
61
+ tokenize_function, batched=True, remove_columns=Dataset["train"].column_names
62
+ )
63
+
64
+ data_collator = DataCollatorForSeq2Seq(tokenizer, model=transformer_model)
65
+
66
+ def compute_metrics(eval_pred):
67
+ predictions, labels = eval_pred
68
+ decoded_preds = tokenizer.batch_decode(predictions, skip_special_tokens=True)
69
+ labels = [[label if label != -100 else tokenizer.pad_token_id for label in l] for l in labels]
70
+ decoded_labels = tokenizer.batch_decode(labels, skip_special_tokens=True)
71
+
72
+ decoded_preds = [pred.strip() for pred in decoded_preds]
73
+ decoded_labels = [label.strip() for label in decoded_labels]
74
+
75
+ bleu_result = bleu.compute(predictions=decoded_preds, references=decoded_labels)
76
+ rouge_result = rouge.compute(predictions=decoded_preds, references=decoded_labels)
77
+
78
+ return {
79
+ "bleu": bleu_result["bleu"],
80
+ "rouge1": rouge_result["rouge1"],
81
+ "rouge2": rouge_result["rouge2"],
82
+ "rougeL": rouge_result["rougeL"]
83
+ }
84
+
85
+ training_args = Seq2SeqTrainingArguments(
86
+ output_dir="./sign_language_converter_model",
87
+ eval_strategy="epoch",
88
+ save_strategy="epoch",
89
+ learning_rate=3e-5,
90
+ per_device_train_batch_size=8,
91
+ per_device_eval_batch_size=8,
92
+ num_train_epochs=5,
93
+ weight_decay=0.01,
94
+ predict_with_generate=True,
95
+ save_total_limit=2,
96
+ logging_steps=100,
97
+ fp16=True, # set to True if using a GPU with mixed precision support
98
+ report_to="mlflow",
99
+ )
100
+
101
+ trainer = Seq2SeqTrainer(
102
+ model=transformer_model,
103
+ args=training_args,
104
+ train_dataset=tokenized_dataset["train"],
105
+ eval_dataset=tokenized_dataset["test"],
106
+ data_collator=data_collator,
107
+ compute_metrics=compute_metrics
108
+ )
109
+
110
+ mlflow.set_tracking_uri("./mlruns")
111
+ mlflow.set_experiment("Sign Language Text Conversion")
112
+ with mlflow.start_run(run_name="T5 Sign Language Converter"):
113
+ trainer.train()
114
+ trainer.save_model("./sign_language_converter_model")
115
+ tokenizer.save_pretrained("./sign_language_converter_model")
116
+
117
+ # Test the trained model on some example sentences
118
+ def convert_to_sign_friendly(text: str) -> str:
119
+ inputs = tokenizer("convert to sign-friendly: " + text, return_tensors="pt", truncation=True).input_ids.to(transformer_model.device)
120
+ outputs = transformer_model.generate(inputs, max_length=max_target_length, num_beams=4, early_stopping=True)
121
+ sign_friendly_text = tokenizer.decode(outputs[0], skip_special_tokens=True)
122
+ return sign_friendly_text.strip()
123
+
124
+ if __name__ == "__main__":
125
+ test_sentence = "I want to go to the store"
126
+ print("Original:", test_sentence)
127
+ print("Sign-friendly:", convert_to_sign_friendly(test_sentence))
src/sign_app/ui/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """UI and API interfaces module."""
src/sign_app/ui/avatar.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c22dc6f68103100395277bf585d51b7b8d1c509d103f510ff05fb5538f63745a
3
+ size 935628
src/sign_app/ui/fingerspellDictionary.js ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ASL Fingerspelling Dictionary
3
+ *
4
+ * Each letter maps to rotations for ALL finger bones (3 knuckles each)
5
+ * plus thumb (3 joints) and optional wrist rotation.
6
+ *
7
+ * Values are in radians:
8
+ * 0 = straight / open
9
+ * 1.2 = fully curled
10
+ * 0.6 = half curled
11
+ *
12
+ * Thumb uses both X (curl) and Z (spread/opposition) axes.
13
+ */
14
+ export const FINGERSPELL = {
15
+
16
+ A: {
17
+ thumb1: { x: 0.2, y: 0, z: 0.3 },
18
+ thumb2: { x: 0.1, y: 0, z: 0 },
19
+ thumb3: { x: 0, y: 0, z: 0 },
20
+ index1: { x: 1.2, y: 0, z: 0 }, index2: { x: 1.2, y: 0, z: 0 }, index3: { x: 1.0, y: 0, z: 0 },
21
+ middle1: { x: 1.2, y: 0, z: 0 }, middle2: { x: 1.2, y: 0, z: 0 }, middle3: { x: 1.0, y: 0, z: 0 },
22
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.2, y: 0, z: 0 }, ring3: { x: 1.0, y: 0, z: 0 },
23
+ pinky1: { x: 1.2, y: 0, z: 0 }, pinky2: { x: 1.2, y: 0, z: 0 }, pinky3: { x: 1.0, y: 0, z: 0 },
24
+ wrist: { x: 0, y: 0, z: 0 }
25
+ },
26
+
27
+ B: {
28
+ thumb1: { x: 0.8, y: 0, z: 0.4 },
29
+ thumb2: { x: 0.6, y: 0, z: 0 },
30
+ thumb3: { x: 0.3, y: 0, z: 0 },
31
+ index1: { x: 0, y: 0, z: 0 }, index2: { x: 0, y: 0, z: 0 }, index3: { x: 0, y: 0, z: 0 },
32
+ middle1: { x: 0, y: 0, z: 0 }, middle2: { x: 0, y: 0, z: 0 }, middle3: { x: 0, y: 0, z: 0 },
33
+ ring1: { x: 0, y: 0, z: 0 }, ring2: { x: 0, y: 0, z: 0 }, ring3: { x: 0, y: 0, z: 0 },
34
+ pinky1: { x: 0, y: 0, z: 0 }, pinky2: { x: 0, y: 0, z: 0 }, pinky3: { x: 0, y: 0, z: 0 },
35
+ wrist: { x: 0, y: 0, z: 0 }
36
+ },
37
+
38
+ C: {
39
+ thumb1: { x: 0.3, y: 0, z: 0.2 },
40
+ thumb2: { x: 0.3, y: 0, z: 0 },
41
+ thumb3: { x: 0.2, y: 0, z: 0 },
42
+ index1: { x: 0.5, y: 0, z: 0 }, index2: { x: 0.4, y: 0, z: 0 }, index3: { x: 0.3, y: 0, z: 0 },
43
+ middle1: { x: 0.5, y: 0, z: 0 }, middle2: { x: 0.4, y: 0, z: 0 }, middle3: { x: 0.3, y: 0, z: 0 },
44
+ ring1: { x: 0.5, y: 0, z: 0 }, ring2: { x: 0.4, y: 0, z: 0 }, ring3: { x: 0.3, y: 0, z: 0 },
45
+ pinky1: { x: 0.5, y: 0, z: 0 }, pinky2: { x: 0.4, y: 0, z: 0 }, pinky3: { x: 0.3, y: 0, z: 0 },
46
+ wrist: { x: 0, y: 0, z: 0 }
47
+ },
48
+
49
+ D: {
50
+ thumb1: { x: 0.6, y: 0, z: 0.4 },
51
+ thumb2: { x: 0.5, y: 0, z: 0 },
52
+ thumb3: { x: 0.3, y: 0, z: 0 },
53
+ index1: { x: 0, y: 0, z: 0 }, index2: { x: 0, y: 0, z: 0 }, index3: { x: 0, y: 0, z: 0 },
54
+ middle1: { x: 1.2, y: 0, z: 0 }, middle2: { x: 1.0, y: 0, z: 0 }, middle3: { x: 0.8, y: 0, z: 0 },
55
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.0, y: 0, z: 0 }, ring3: { x: 0.8, y: 0, z: 0 },
56
+ pinky1: { x: 1.2, y: 0, z: 0 }, pinky2: { x: 1.0, y: 0, z: 0 }, pinky3: { x: 0.8, y: 0, z: 0 },
57
+ wrist: { x: 0, y: 0, z: 0 }
58
+ },
59
+
60
+ E: {
61
+ thumb1: { x: 0.3, y: 0, z: 0.3 },
62
+ thumb2: { x: 0.2, y: 0, z: 0 },
63
+ thumb3: { x: 0.1, y: 0, z: 0 },
64
+ index1: { x: 1.0, y: 0, z: 0 }, index2: { x: 0.8, y: 0, z: 0 }, index3: { x: 0.6, y: 0, z: 0 },
65
+ middle1: { x: 1.0, y: 0, z: 0 }, middle2: { x: 0.8, y: 0, z: 0 }, middle3: { x: 0.6, y: 0, z: 0 },
66
+ ring1: { x: 1.0, y: 0, z: 0 }, ring2: { x: 0.8, y: 0, z: 0 }, ring3: { x: 0.6, y: 0, z: 0 },
67
+ pinky1: { x: 1.0, y: 0, z: 0 }, pinky2: { x: 0.8, y: 0, z: 0 }, pinky3: { x: 0.6, y: 0, z: 0 },
68
+ wrist: { x: 0, y: 0, z: 0 }
69
+ },
70
+
71
+ F: {
72
+ thumb1: { x: 0.6, y: 0, z: 0.3 },
73
+ thumb2: { x: 0.5, y: 0, z: 0 },
74
+ thumb3: { x: 0.3, y: 0, z: 0 },
75
+ index1: { x: 0.8, y: 0, z: 0 }, index2: { x: 0.6, y: 0, z: 0 }, index3: { x: 0.4, y: 0, z: 0 },
76
+ middle1: { x: 0, y: 0, z: 0 }, middle2: { x: 0, y: 0, z: 0 }, middle3: { x: 0, y: 0, z: 0 },
77
+ ring1: { x: 0, y: 0, z: 0 }, ring2: { x: 0, y: 0, z: 0 }, ring3: { x: 0, y: 0, z: 0 },
78
+ pinky1: { x: 0, y: 0, z: 0 }, pinky2: { x: 0, y: 0, z: 0 }, pinky3: { x: 0, y: 0, z: 0 },
79
+ wrist: { x: 0, y: 0, z: 0 }
80
+ },
81
+
82
+ G: {
83
+ thumb1: { x: 0.2, y: 0, z: -0.4 },
84
+ thumb2: { x: 0.1, y: 0, z: 0 },
85
+ thumb3: { x: 0, y: 0, z: 0 },
86
+ index1: { x: 0, y: 0, z: 0 }, index2: { x: 0, y: 0, z: 0 }, index3: { x: 0, y: 0, z: 0 },
87
+ middle1: { x: 1.2, y: 0, z: 0 }, middle2: { x: 1.0, y: 0, z: 0 }, middle3: { x: 0.8, y: 0, z: 0 },
88
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.0, y: 0, z: 0 }, ring3: { x: 0.8, y: 0, z: 0 },
89
+ pinky1: { x: 1.2, y: 0, z: 0 }, pinky2: { x: 1.0, y: 0, z: 0 }, pinky3: { x: 0.8, y: 0, z: 0 },
90
+ wrist: { x: 0, y: -0.5, z: 0 }
91
+ },
92
+
93
+ H: {
94
+ thumb1: { x: 0.3, y: 0, z: 0.3 },
95
+ thumb2: { x: 0.2, y: 0, z: 0 },
96
+ thumb3: { x: 0.1, y: 0, z: 0 },
97
+ index1: { x: 0, y: 0, z: 0 }, index2: { x: 0, y: 0, z: 0 }, index3: { x: 0, y: 0, z: 0 },
98
+ middle1: { x: 0, y: 0, z: 0 }, middle2: { x: 0, y: 0, z: 0 }, middle3: { x: 0, y: 0, z: 0 },
99
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.0, y: 0, z: 0 }, ring3: { x: 0.8, y: 0, z: 0 },
100
+ pinky1: { x: 1.2, y: 0, z: 0 }, pinky2: { x: 1.0, y: 0, z: 0 }, pinky3: { x: 0.8, y: 0, z: 0 },
101
+ wrist: { x: 0, y: -0.5, z: 0 }
102
+ },
103
+
104
+ I: {
105
+ thumb1: { x: 0.6, y: 0, z: 0.3 },
106
+ thumb2: { x: 0.5, y: 0, z: 0 },
107
+ thumb3: { x: 0.3, y: 0, z: 0 },
108
+ index1: { x: 1.2, y: 0, z: 0 }, index2: { x: 1.0, y: 0, z: 0 }, index3: { x: 0.8, y: 0, z: 0 },
109
+ middle1: { x: 1.2, y: 0, z: 0 }, middle2: { x: 1.0, y: 0, z: 0 }, middle3: { x: 0.8, y: 0, z: 0 },
110
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.0, y: 0, z: 0 }, ring3: { x: 0.8, y: 0, z: 0 },
111
+ pinky1: { x: 0, y: 0, z: 0 }, pinky2: { x: 0, y: 0, z: 0 }, pinky3: { x: 0, y: 0, z: 0 },
112
+ wrist: { x: 0, y: 0, z: 0 }
113
+ },
114
+
115
+ J: {
116
+ thumb1: { x: 0.6, y: 0, z: 0.3 },
117
+ thumb2: { x: 0.5, y: 0, z: 0 },
118
+ thumb3: { x: 0.3, y: 0, z: 0 },
119
+ index1: { x: 1.2, y: 0, z: 0 }, index2: { x: 1.0, y: 0, z: 0 }, index3: { x: 0.8, y: 0, z: 0 },
120
+ middle1: { x: 1.2, y: 0, z: 0 }, middle2: { x: 1.0, y: 0, z: 0 }, middle3: { x: 0.8, y: 0, z: 0 },
121
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.0, y: 0, z: 0 }, ring3: { x: 0.8, y: 0, z: 0 },
122
+ pinky1: { x: 0, y: 0, z: 0 }, pinky2: { x: 0, y: 0, z: 0 }, pinky3: { x: 0, y: 0, z: 0 },
123
+ wrist: { x: 0, y: 0.6, z: 0 } // J = I + wrist hook
124
+ },
125
+
126
+ K: {
127
+ thumb1: { x: 0.3, y: 0, z: 0.2 },
128
+ thumb2: { x: 0.2, y: 0, z: 0 },
129
+ thumb3: { x: 0.1, y: 0, z: 0 },
130
+ index1: { x: 0, y: 0, z: 0 }, index2: { x: 0, y: 0, z: 0 }, index3: { x: 0, y: 0, z: 0 },
131
+ middle1: { x: 0, y: 0, z: 0 }, middle2: { x: 0, y: 0, z: 0 }, middle3: { x: 0, y: 0, z: 0 },
132
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.0, y: 0, z: 0 }, ring3: { x: 0.8, y: 0, z: 0 },
133
+ pinky1: { x: 1.2, y: 0, z: 0 }, pinky2: { x: 1.0, y: 0, z: 0 }, pinky3: { x: 0.8, y: 0, z: 0 },
134
+ wrist: { x: 0, y: 0, z: 0 }
135
+ },
136
+
137
+ L: {
138
+ thumb1: { x: 0, y: 0, z: -0.6 },
139
+ thumb2: { x: 0, y: 0, z: 0 },
140
+ thumb3: { x: 0, y: 0, z: 0 },
141
+ index1: { x: 0, y: 0, z: 0 }, index2: { x: 0, y: 0, z: 0 }, index3: { x: 0, y: 0, z: 0 },
142
+ middle1: { x: 1.2, y: 0, z: 0 }, middle2: { x: 1.0, y: 0, z: 0 }, middle3: { x: 0.8, y: 0, z: 0 },
143
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.0, y: 0, z: 0 }, ring3: { x: 0.8, y: 0, z: 0 },
144
+ pinky1: { x: 1.2, y: 0, z: 0 }, pinky2: { x: 1.0, y: 0, z: 0 }, pinky3: { x: 0.8, y: 0, z: 0 },
145
+ wrist: { x: 0, y: 0, z: 0 }
146
+ },
147
+
148
+ M: {
149
+ thumb1: { x: 0.6, y: 0, z: 0.4 },
150
+ thumb2: { x: 0.5, y: 0, z: 0 },
151
+ thumb3: { x: 0.3, y: 0, z: 0 },
152
+ index1: { x: 1.2, y: 0, z: 0 }, index2: { x: 1.0, y: 0, z: 0 }, index3: { x: 0.8, y: 0, z: 0 },
153
+ middle1: { x: 1.2, y: 0, z: 0 }, middle2: { x: 1.0, y: 0, z: 0 }, middle3: { x: 0.8, y: 0, z: 0 },
154
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.0, y: 0, z: 0 }, ring3: { x: 0.8, y: 0, z: 0 },
155
+ pinky1: { x: 1.2, y: 0, z: 0 }, pinky2: { x: 1.0, y: 0, z: 0 }, pinky3: { x: 0.8, y: 0, z: 0 },
156
+ wrist: { x: 0, y: 0, z: 0 }
157
+ },
158
+
159
+ N: {
160
+ thumb1: { x: 0.6, y: 0, z: 0.4 },
161
+ thumb2: { x: 0.5, y: 0, z: 0 },
162
+ thumb3: { x: 0.3, y: 0, z: 0 },
163
+ index1: { x: 1.2, y: 0, z: 0 }, index2: { x: 1.0, y: 0, z: 0 }, index3: { x: 0.8, y: 0, z: 0 },
164
+ middle1: { x: 1.2, y: 0, z: 0 }, middle2: { x: 1.0, y: 0, z: 0 }, middle3: { x: 0.8, y: 0, z: 0 },
165
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.0, y: 0, z: 0 }, ring3: { x: 0.8, y: 0, z: 0 },
166
+ pinky1: { x: 1.2, y: 0, z: 0 }, pinky2: { x: 1.0, y: 0, z: 0 }, pinky3: { x: 0.8, y: 0, z: 0 },
167
+ wrist: { x: 0, y: 0, z: 0 }
168
+ },
169
+
170
+ O: {
171
+ thumb1: { x: 0.4, y: 0, z: 0.3 },
172
+ thumb2: { x: 0.3, y: 0, z: 0 },
173
+ thumb3: { x: 0.2, y: 0, z: 0 },
174
+ index1: { x: 0.6, y: 0, z: 0 }, index2: { x: 0.5, y: 0, z: 0 }, index3: { x: 0.4, y: 0, z: 0 },
175
+ middle1: { x: 0.6, y: 0, z: 0 }, middle2: { x: 0.5, y: 0, z: 0 }, middle3: { x: 0.4, y: 0, z: 0 },
176
+ ring1: { x: 0.6, y: 0, z: 0 }, ring2: { x: 0.5, y: 0, z: 0 }, ring3: { x: 0.4, y: 0, z: 0 },
177
+ pinky1: { x: 0.6, y: 0, z: 0 }, pinky2: { x: 0.5, y: 0, z: 0 }, pinky3: { x: 0.4, y: 0, z: 0 },
178
+ wrist: { x: 0, y: 0, z: 0 }
179
+ },
180
+
181
+ P: {
182
+ thumb1: { x: 0.2, y: 0, z: -0.3 },
183
+ thumb2: { x: 0.1, y: 0, z: 0 },
184
+ thumb3: { x: 0, y: 0, z: 0 },
185
+ index1: { x: 0, y: 0, z: 0 }, index2: { x: 0, y: 0, z: 0 }, index3: { x: 0, y: 0, z: 0 },
186
+ middle1: { x: 0.4, y: 0, z: 0 }, middle2: { x: 0.3, y: 0, z: 0 }, middle3: { x: 0.2, y: 0, z: 0 },
187
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.0, y: 0, z: 0 }, ring3: { x: 0.8, y: 0, z: 0 },
188
+ pinky1: { x: 1.2, y: 0, z: 0 }, pinky2: { x: 1.0, y: 0, z: 0 }, pinky3: { x: 0.8, y: 0, z: 0 },
189
+ wrist: { x: 0.5, y: 0, z: 0 } // hand tilted down
190
+ },
191
+
192
+ Q: {
193
+ thumb1: { x: 0.3, y: 0, z: -0.3 },
194
+ thumb2: { x: 0.2, y: 0, z: 0 },
195
+ thumb3: { x: 0.1, y: 0, z: 0 },
196
+ index1: { x: 0.4, y: 0, z: 0 }, index2: { x: 0.3, y: 0, z: 0 }, index3: { x: 0.2, y: 0, z: 0 },
197
+ middle1: { x: 1.2, y: 0, z: 0 }, middle2: { x: 1.0, y: 0, z: 0 }, middle3: { x: 0.8, y: 0, z: 0 },
198
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.0, y: 0, z: 0 }, ring3: { x: 0.8, y: 0, z: 0 },
199
+ pinky1: { x: 1.2, y: 0, z: 0 }, pinky2: { x: 1.0, y: 0, z: 0 }, pinky3: { x: 0.8, y: 0, z: 0 },
200
+ wrist: { x: 0.5, y: 0, z: 0 }
201
+ },
202
+
203
+ R: {
204
+ thumb1: { x: 0.6, y: 0, z: 0.3 },
205
+ thumb2: { x: 0.5, y: 0, z: 0 },
206
+ thumb3: { x: 0.3, y: 0, z: 0 },
207
+ index1: { x: 0, y: 0, z: -0.1 }, index2: { x: 0, y: 0, z: 0 }, index3: { x: 0, y: 0, z: 0 },
208
+ middle1: { x: 0.1, y: 0, z: 0.2 }, middle2: { x: 0.2, y: 0, z: 0 }, middle3: { x: 0.1, y: 0, z: 0 },
209
+ ring1: { x: 1.5, y: 0, z: 0 }, ring2: { x: 1.2, y: 0, z: 0 }, ring3: { x: 1.0, y: 0, z: 0 },
210
+ pinky1: { x: 1.5, y: 0, z: 0 }, pinky2: { x: 1.2, y: 0, z: 0 }, pinky3: { x: 1.0, y: 0, z: 0 },
211
+ wrist: { x: 0, y: 0, z: 0 }
212
+ },
213
+
214
+ S: {
215
+ thumb1: { x: 0.3, y: 0, z: 0.3 },
216
+ thumb2: { x: 0.2, y: 0, z: 0 },
217
+ thumb3: { x: 0.1, y: 0, z: 0 },
218
+ index1: { x: 1.2, y: 0, z: 0 }, index2: { x: 1.2, y: 0, z: 0 }, index3: { x: 1.0, y: 0, z: 0 },
219
+ middle1: { x: 1.2, y: 0, z: 0 }, middle2: { x: 1.2, y: 0, z: 0 }, middle3: { x: 1.0, y: 0, z: 0 },
220
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.2, y: 0, z: 0 }, ring3: { x: 1.0, y: 0, z: 0 },
221
+ pinky1: { x: 1.2, y: 0, z: 0 }, pinky2: { x: 1.2, y: 0, z: 0 }, pinky3: { x: 1.0, y: 0, z: 0 },
222
+ wrist: { x: 0, y: 0, z: 0 }
223
+ },
224
+
225
+ T: {
226
+ thumb1: { x: 0.2, y: 0, z: 0.2 },
227
+ thumb2: { x: 0.2, y: 0, z: 0 },
228
+ thumb3: { x: 0.1, y: 0, z: 0 },
229
+ index1: { x: 1.2, y: 0, z: 0 }, index2: { x: 1.0, y: 0, z: 0 }, index3: { x: 0.8, y: 0, z: 0 },
230
+ middle1: { x: 1.2, y: 0, z: 0 }, middle2: { x: 1.0, y: 0, z: 0 }, middle3: { x: 0.8, y: 0, z: 0 },
231
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.0, y: 0, z: 0 }, ring3: { x: 0.8, y: 0, z: 0 },
232
+ pinky1: { x: 1.2, y: 0, z: 0 }, pinky2: { x: 1.0, y: 0, z: 0 }, pinky3: { x: 0.8, y: 0, z: 0 },
233
+ wrist: { x: 0, y: 0, z: 0 }
234
+ },
235
+
236
+ U: {
237
+ thumb1: { x: 0.6, y: 0, z: 0.3 },
238
+ thumb2: { x: 0.5, y: 0, z: 0 },
239
+ thumb3: { x: 0.3, y: 0, z: 0 },
240
+ index1: { x: 0, y: 0, z: 0 }, index2: { x: 0, y: 0, z: 0 }, index3: { x: 0, y: 0, z: 0 },
241
+ middle1: { x: 0, y: 0, z: 0 }, middle2: { x: 0, y: 0, z: 0 }, middle3: { x: 0, y: 0, z: 0 },
242
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.0, y: 0, z: 0 }, ring3: { x: 0.8, y: 0, z: 0 },
243
+ pinky1: { x: 1.2, y: 0, z: 0 }, pinky2: { x: 1.0, y: 0, z: 0 }, pinky3: { x: 0.8, y: 0, z: 0 },
244
+ wrist: { x: 0, y: 0, z: 0 }
245
+ },
246
+
247
+ V: {
248
+ thumb1: { x: 0.6, y: 0, z: 0.3 },
249
+ thumb2: { x: 0.5, y: 0, z: 0 },
250
+ thumb3: { x: 0.3, y: 0, z: 0 },
251
+ index1: { x: 0, y: 0, z: -0.15 }, index2: { x: 0, y: 0, z: 0 }, index3: { x: 0, y: 0, z: 0 },
252
+ middle1: { x: 0, y: 0, z: 0.15 }, middle2: { x: 0, y: 0, z: 0 }, middle3: { x: 0, y: 0, z: 0 },
253
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.0, y: 0, z: 0 }, ring3: { x: 0.8, y: 0, z: 0 },
254
+ pinky1: { x: 1.2, y: 0, z: 0 }, pinky2: { x: 1.0, y: 0, z: 0 }, pinky3: { x: 0.8, y: 0, z: 0 },
255
+ wrist: { x: 0, y: 0, z: 0 }
256
+ },
257
+
258
+ W: {
259
+ thumb1: { x: 0.6, y: 0, z: 0.4 },
260
+ thumb2: { x: 0.5, y: 0, z: 0 },
261
+ thumb3: { x: 0.3, y: 0, z: 0 },
262
+ index1: { x: 0, y: 0, z: -0.15 }, index2: { x: 0, y: 0, z: 0 }, index3: { x: 0, y: 0, z: 0 },
263
+ middle1: { x: 0, y: 0, z: 0 }, middle2: { x: 0, y: 0, z: 0 }, middle3: { x: 0, y: 0, z: 0 },
264
+ ring1: { x: 0, y: 0, z: 0.15 }, ring2: { x: 0, y: 0, z: 0 }, ring3: { x: 0, y: 0, z: 0 },
265
+ pinky1: { x: 1.2, y: 0, z: 0 }, pinky2: { x: 1.0, y: 0, z: 0 }, pinky3: { x: 0.8, y: 0, z: 0 },
266
+ wrist: { x: 0, y: 0, z: 0 }
267
+ },
268
+
269
+ X: {
270
+ thumb1: { x: 0.6, y: 0, z: 0.3 },
271
+ thumb2: { x: 0.5, y: 0, z: 0 },
272
+ thumb3: { x: 0.3, y: 0, z: 0 },
273
+ index1: { x: 0.6, y: 0, z: 0 }, index2: { x: 0.8, y: 0, z: 0 }, index3: { x: 0.6, y: 0, z: 0 },
274
+ middle1: { x: 1.2, y: 0, z: 0 }, middle2: { x: 1.0, y: 0, z: 0 }, middle3: { x: 0.8, y: 0, z: 0 },
275
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.0, y: 0, z: 0 }, ring3: { x: 0.8, y: 0, z: 0 },
276
+ pinky1: { x: 1.2, y: 0, z: 0 }, pinky2: { x: 1.0, y: 0, z: 0 }, pinky3: { x: 0.8, y: 0, z: 0 },
277
+ wrist: { x: 0, y: 0, z: 0 }
278
+ },
279
+
280
+ Y: {
281
+ thumb1: { x: 0, y: 0, z: -0.5 },
282
+ thumb2: { x: 0, y: 0, z: 0 },
283
+ thumb3: { x: 0, y: 0, z: 0 },
284
+ index1: { x: 1.2, y: 0, z: 0 }, index2: { x: 1.0, y: 0, z: 0 }, index3: { x: 0.8, y: 0, z: 0 },
285
+ middle1: { x: 1.2, y: 0, z: 0 }, middle2: { x: 1.0, y: 0, z: 0 }, middle3: { x: 0.8, y: 0, z: 0 },
286
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.0, y: 0, z: 0 }, ring3: { x: 0.8, y: 0, z: 0 },
287
+ pinky1: { x: 0, y: 0, z: 0 }, pinky2: { x: 0, y: 0, z: 0 }, pinky3: { x: 0, y: 0, z: 0 },
288
+ wrist: { x: 0, y: 0, z: 0 }
289
+ },
290
+
291
+ Z: {
292
+ thumb1: { x: 0.6, y: 0, z: 0.3 },
293
+ thumb2: { x: 0.5, y: 0, z: 0 },
294
+ thumb3: { x: 0.3, y: 0, z: 0 },
295
+ index1: { x: 0, y: 0, z: 0 }, index2: { x: 0, y: 0, z: 0 }, index3: { x: 0, y: 0, z: 0 },
296
+ middle1: { x: 1.2, y: 0, z: 0 }, middle2: { x: 1.0, y: 0, z: 0 }, middle3: { x: 0.8, y: 0, z: 0 },
297
+ ring1: { x: 1.2, y: 0, z: 0 }, ring2: { x: 1.0, y: 0, z: 0 }, ring3: { x: 0.8, y: 0, z: 0 },
298
+ pinky1: { x: 1.2, y: 0, z: 0 }, pinky2: { x: 1.0, y: 0, z: 0 }, pinky3: { x: 0.8, y: 0, z: 0 },
299
+ wrist: { x: 0, y: 0.4, z: 0 } // Z = index point + wrist trace
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Maps short key names to actual avatar bone names
305
+ */
306
+ export const FINGER_BONE_MAP = {
307
+ thumb1: "RightHandThumb1",
308
+ thumb2: "RightHandThumb2",
309
+ thumb3: "RightHandThumb3",
310
+ index1: "RightHandIndex1",
311
+ index2: "RightHandIndex2",
312
+ index3: "RightHandIndex3",
313
+ middle1: "RightHandMiddle1",
314
+ middle2: "RightHandMiddle2",
315
+ middle3: "RightHandMiddle3",
316
+ ring1: "RightHandRing1",
317
+ ring2: "RightHandRing2",
318
+ ring3: "RightHandRing3",
319
+ pinky1: "RightHandPinky1",
320
+ pinky2: "RightHandPinky2",
321
+ pinky3: "RightHandPinky3",
322
+ wrist: "RightHand"
323
+ }
src/sign_app/ui/index.html ADDED
@@ -0,0 +1,511 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>SignApp - Speech to Sign Language</title>
8
+ <meta name="description" content="Real-time speech to sign language avatar powered by AI">
9
+
10
+ <link rel="preconnect" href="https://fonts.googleapis.com">
11
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
12
+
13
+ <script type="importmap">
14
+ {
15
+ "imports": {
16
+ "three": "https://unpkg.com/three@0.158.0/build/three.module.js",
17
+ "three/addons/": "https://unpkg.com/three@0.158.0/examples/jsm/"
18
+ }
19
+ }
20
+ </script>
21
+
22
+ <style>
23
+
24
+ :root {
25
+ --bg-color-top: #0c0a1a;
26
+ --bg-color-mid: #1a1333;
27
+ --bg-color-bot: #0d1b2a;
28
+ --text-main: #e2e8f0;
29
+ --text-main-muted: #cbd5e1;
30
+ --text-muted: #64748b;
31
+ --topbar-bg: rgba(12,10,26,0.9);
32
+ --bottom-panel-bg1: rgba(12,10,26,0.85);
33
+ --bottom-panel-bg2: rgba(12,10,26,0.4);
34
+ --chip-bg: rgba(255,255,255,0.06);
35
+ --chip-border: rgba(255,255,255,0.08);
36
+ --input-bg: rgba(255,255,255,0.05);
37
+ --input-border: rgba(255,255,255,0.1);
38
+ --input-focus: rgba(167,139,250,0.4);
39
+ --accent-color: #a78bfa;
40
+ --accent-hover: rgba(167,139,250,0.3);
41
+ --btn-bg: rgba(167,139,250,0.2);
42
+ --btn-border: rgba(167,139,250,0.3);
43
+ --btn-text: #c4b5fd;
44
+ --btn-hover-bg: rgba(167,139,250,0.3);
45
+ --btn-hover-text: #fff;
46
+ --btn-sec-bg: rgba(255,255,255,0.05);
47
+ --btn-sec-border: rgba(255,255,255,0.1);
48
+ --btn-sec-hover-bg: rgba(255,255,255,0.1);
49
+ --btn-sec-hover-border: rgba(255,255,255,0.2);
50
+ --btn-sec-text: #94a3b8;
51
+ --btn-sec-hover-text: #fff;
52
+ --status-dot-processing: #f59e0b;
53
+ --gloss-token-bg: rgba(167,139,250,0.15);
54
+ --gloss-token-border: rgba(167,139,250,0.25);
55
+ --gloss-token-text: #c4b5fd;
56
+ --gloss-token-active-bg: rgba(167,139,250,0.35);
57
+ --gloss-token-active-border: #a78bfa;
58
+ --gloss-token-active-text: #fff;
59
+ }
60
+ .light-mode {
61
+ --bg-color-top: #f8fafc;
62
+ --bg-color-mid: #e2e8f0;
63
+ --bg-color-bot: #cbd5e1;
64
+ --text-main: #1e293b;
65
+ --text-main-muted: #334155;
66
+ --text-muted: #475569;
67
+ --topbar-bg: rgba(248, 250, 252, 0.9);
68
+ --bottom-panel-bg1: rgba(248, 250, 252, 0.95);
69
+ --bottom-panel-bg2: rgba(248, 250, 252, 0.6);
70
+ --chip-bg: rgba(0,0,0,0.05);
71
+ --chip-border: rgba(0,0,0,0.1);
72
+ --input-bg: rgba(0,0,0,0.05);
73
+ --input-border: rgba(0,0,0,0.1);
74
+ --input-focus: rgba(124,58,237,0.4);
75
+ --accent-color: #7c3aed;
76
+ --accent-hover: rgba(124, 58, 237, 0.15);
77
+ --btn-bg: rgba(124, 58, 237, 0.1);
78
+ --btn-border: rgba(124, 58, 237, 0.2);
79
+ --btn-text: #6d28d9;
80
+ --btn-hover-bg: rgba(124, 58, 237, 0.2);
81
+ --btn-hover-text: #1e293b;
82
+ --btn-sec-bg: rgba(0,0,0,0.05);
83
+ --btn-sec-border: rgba(0,0,0,0.1);
84
+ --btn-sec-hover-bg: rgba(0,0,0,0.1);
85
+ --btn-sec-hover-border: rgba(0,0,0,0.2);
86
+ --btn-sec-text: #475569;
87
+ --btn-sec-hover-text: #1e293b;
88
+ --status-dot-processing: #d97706;
89
+ --gloss-token-bg: rgba(124, 58, 237, 0.1);
90
+ --gloss-token-border: rgba(124, 58, 237, 0.2);
91
+ --gloss-token-text: #6d28d9;
92
+ --gloss-token-active-bg: rgba(124, 58, 237, 0.25);
93
+ --gloss-token-active-border: #7c3aed;
94
+ --gloss-token-active-text: #1e293b;
95
+ }
96
+
97
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
98
+
99
+ body {
100
+ font-family: 'Inter', sans-serif;
101
+ background: linear-gradient(135deg, var(--bg-color-top) 0%, var(--bg-color-mid) 40%, var(--bg-color-bot) 100%);
102
+ color: var(--text-main);
103
+ overflow: hidden;
104
+ height: 100vh;
105
+ width: 100vw;
106
+ }
107
+
108
+ /* ── 3D Canvas ─────────────────────────────── */
109
+ #avatar-canvas {
110
+ position: fixed;
111
+ top: 0; left: 0;
112
+ width: 100%; height: 100%;
113
+ z-index: 0;
114
+ }
115
+
116
+ /* ── Top Bar ───────────────────────────────── */
117
+ #top-bar {
118
+ position: fixed;
119
+ top: 0; left: 0; right: 0;
120
+ z-index: 20;
121
+ display: flex;
122
+ align-items: center;
123
+ justify-content: space-between;
124
+ padding: 16px 28px;
125
+ background: linear-gradient(180deg, var(--topbar-bg) 0%, transparent 100%);
126
+ }
127
+
128
+ .logo {
129
+ font-weight: 700;
130
+ font-size: 20px;
131
+ letter-spacing: -0.5px;
132
+ background: linear-gradient(135deg, #a78bfa, #60a5fa);
133
+ -webkit-background-clip: text;
134
+ background-clip: text;
135
+ -webkit-text-fill-color: transparent;
136
+ }
137
+
138
+ .status-chip {
139
+ display: flex;
140
+ align-items: center;
141
+ gap: 8px;
142
+ padding: 6px 14px;
143
+ border-radius: 20px;
144
+ font-size: 13px;
145
+ font-weight: 500;
146
+ background: var(--chip-bg);
147
+ border: 1px solid var(--chip-border);
148
+ backdrop-filter: blur(10px);
149
+ transition: all 0.3s ease;
150
+ }
151
+
152
+ .status-dot {
153
+ width: 8px; height: 8px;
154
+ border-radius: 50%;
155
+ background: #4ade80;
156
+ transition: background 0.3s;
157
+ }
158
+
159
+ .status-dot.recording {
160
+ background: #ef4444;
161
+ animation: pulse-dot 1s ease-in-out infinite;
162
+ }
163
+
164
+ .status-dot.processing {
165
+ background: var(--status-dot-processing);
166
+ animation: pulse-dot 0.6s ease-in-out infinite;
167
+ }
168
+
169
+ .status-dot.signing {
170
+ background: var(--accent-color);
171
+ animation: pulse-dot 1.2s ease-in-out infinite;
172
+ }
173
+
174
+ @keyframes pulse-dot {
175
+ 0%, 100% { opacity: 1; transform: scale(1); }
176
+ 50% { opacity: 0.5; transform: scale(1.3); }
177
+ }
178
+
179
+ /* ── Bottom Panel ──────────────────────────── */
180
+ #bottom-panel {
181
+ position: fixed;
182
+ bottom: 0; left: 0; right: 0;
183
+ z-index: 20;
184
+ display: flex;
185
+ flex-direction: column;
186
+ align-items: center;
187
+ gap: 16px;
188
+ padding: 20px 28px 32px;
189
+ background: linear-gradient(0deg, var(--bottom-panel-bg1) 0%, var(--bottom-panel-bg2) 70%, transparent 100%);
190
+ pointer-events: none;
191
+ }
192
+
193
+ #bottom-panel > * {
194
+ pointer-events: auto;
195
+ }
196
+
197
+
198
+ /* ── Transcript Area ───────────────────────── */
199
+ #transcript-area {
200
+ width: 100%;
201
+ max-width: 640px;
202
+ text-align: center;
203
+ min-height: 50px;
204
+ }
205
+
206
+ .transcript-label {
207
+ font-size: 11px;
208
+ text-transform: uppercase;
209
+ letter-spacing: 1.5px;
210
+ color: var(--text-muted);
211
+ margin-bottom: 6px;
212
+ }
213
+
214
+ .transcript-text {
215
+ font-size: 15px;
216
+ color: var(--text-main-muted);
217
+ line-height: 1.5;
218
+ opacity: 0;
219
+ transform: translateY(8px);
220
+ transition: all 0.4s ease;
221
+ }
222
+
223
+ .transcript-text.visible {
224
+ opacity: 1;
225
+ transform: translateY(0);
226
+ }
227
+
228
+ .gloss-tokens {
229
+ display: flex;
230
+ flex-wrap: wrap;
231
+ gap: 6px;
232
+ justify-content: center;
233
+ margin-top: 8px;
234
+ }
235
+
236
+ .gloss-token {
237
+ padding: 4px 10px;
238
+ border-radius: 6px;
239
+ font-size: 13px;
240
+ font-weight: 600;
241
+ font-family: 'Inter', monospace;
242
+ background: var(--gloss-token-bg);
243
+ border: 1px solid var(--gloss-token-border);
244
+ color: var(--gloss-token-text);
245
+ transition: all 0.3s ease;
246
+ }
247
+
248
+ .gloss-token.active {
249
+ background: var(--gloss-token-active-bg);
250
+ border-color: var(--gloss-token-active-border);
251
+ color: #fff;
252
+ transform: scale(1.08);
253
+ box-shadow: 0 0 12px rgba(167,139,250,0.3);
254
+ }
255
+
256
+ .gloss-token.done {
257
+ opacity: 0.4;
258
+ }
259
+
260
+ /* ── Mic Button ────────────────────────────── */
261
+ #mic-btn {
262
+ width: 72px; height: 72px;
263
+ border-radius: 50%;
264
+ border: none;
265
+ cursor: pointer;
266
+ display: flex;
267
+ align-items: center;
268
+ justify-content: center;
269
+ background: linear-gradient(135deg, #7c3aed, #3b82f6);
270
+ box-shadow: 0 4px 24px rgba(124,58,237,0.4), 0 0 0 0 rgba(124,58,237,0);
271
+ transition: all 0.3s ease;
272
+ position: relative;
273
+ }
274
+
275
+ #mic-btn:hover {
276
+ transform: scale(1.06);
277
+ box-shadow: 0 6px 32px rgba(124,58,237,0.5);
278
+ }
279
+
280
+ #mic-btn:active { transform: scale(0.97); }
281
+
282
+ #mic-btn.recording {
283
+ background: linear-gradient(135deg, #ef4444, #dc2626);
284
+ box-shadow: 0 4px 24px rgba(239,68,68,0.4);
285
+ animation: pulse-ring 1.5s ease-out infinite;
286
+ }
287
+
288
+ #mic-btn.processing {
289
+ background: linear-gradient(135deg, #f59e0b, #d97706);
290
+ pointer-events: none;
291
+ animation: spin-slow 2s linear infinite;
292
+ }
293
+
294
+ @keyframes pulse-ring {
295
+ 0% { box-shadow: 0 4px 24px rgba(239,68,68,0.4), 0 0 0 0 rgba(239,68,68,0.3); }
296
+ 100% { box-shadow: 0 4px 24px rgba(239,68,68,0.4), 0 0 0 20px rgba(239,68,68,0); }
297
+ }
298
+
299
+ @keyframes spin-slow {
300
+ from { transform: rotate(0deg); }
301
+ to { transform: rotate(360deg); }
302
+ }
303
+
304
+ .mic-icon {
305
+ width: 28px; height: 28px;
306
+ fill: white;
307
+ }
308
+
309
+ /* ── Audio Waveform Visualizer ──────────────── */
310
+ #waveform {
311
+ width: 200px;
312
+ height: 40px;
313
+ display: flex;
314
+ align-items: center;
315
+ justify-content: center;
316
+ gap: 3px;
317
+ opacity: 0;
318
+ transition: opacity 0.3s;
319
+ }
320
+
321
+ #waveform.active { opacity: 1; }
322
+
323
+ .wave-bar {
324
+ width: 3px;
325
+ height: 8px;
326
+ border-radius: 2px;
327
+ background: var(--accent-color);
328
+ transition: height 0.1s ease;
329
+ }
330
+
331
+ /* ── Signing Progress ──────────────────────── */
332
+ #sign-label {
333
+ font-size: 13px;
334
+ color: var(--text-muted);
335
+ font-weight: 500;
336
+ opacity: 0;
337
+ transition: opacity 0.3s;
338
+ }
339
+
340
+ #sign-label.visible { opacity: 1; }
341
+
342
+ /* ── Text Input Area ────────────────────────── */
343
+ #text-input-container {
344
+ width: 100%;
345
+ max-width: 500px;
346
+ display: flex;
347
+ gap: 10px;
348
+ padding: 4px;
349
+ background: var(--input-bg);
350
+ border: 1px solid var(--input-border);
351
+ border-radius: 12px;
352
+ margin-bottom: 8px;
353
+ transition: all 0.3s ease;
354
+ }
355
+
356
+ #text-input-container:focus-within {
357
+ background: var(--input-bg);
358
+ border-color: var(--input-focus);
359
+ box-shadow: 0 0 15px var(--accent-hover);
360
+ }
361
+
362
+ #text-input {
363
+ color: var(--text-main);
364
+ flex: 1;
365
+ background: transparent;
366
+ border: none;
367
+ outline: none;
368
+ color: #fff;
369
+ padding: 10px 16px;
370
+ font-family: inherit;
371
+ font-size: 14px;
372
+ }
373
+
374
+ #text-input::placeholder { color: var(--text-muted); }
375
+
376
+ #send-btn {
377
+ background: var(--btn-bg);
378
+ border: 1px solid var(--btn-border);
379
+ color: var(--gloss-token-text);
380
+ padding: 0 16px;
381
+ border-radius: 8px;
382
+ cursor: pointer;
383
+ font-size: 13px;
384
+ font-weight: 600;
385
+ transition: all 0.2s ease;
386
+ }
387
+
388
+ #send-btn:hover {
389
+ background: var(--btn-hover-bg);
390
+ color: #fff;
391
+ }
392
+
393
+ /* ── Controls Layout ────────────────────────── */
394
+ .controls-row {
395
+ display: flex;
396
+ align-items: center;
397
+ gap: 20px;
398
+ }
399
+
400
+ /* ── Secondary Button (Replay) ──────────────── */
401
+ .secondary-btn {
402
+ width: 48px; height: 48px;
403
+ border-radius: 50%;
404
+ border: 1px solid var(--input-border);
405
+ background: var(--input-bg);
406
+ cursor: pointer;
407
+ display: flex;
408
+ align-items: center;
409
+ justify-content: center;
410
+ transition: all 0.3s ease;
411
+ color: var(--text-muted);
412
+ }
413
+
414
+ .secondary-btn:hover {
415
+ background: rgba(255,255,255,0.1);
416
+ border-color: rgba(255,255,255,0.2);
417
+ color: #fff;
418
+ transform: translateY(-2px);
419
+ }
420
+
421
+ .secondary-btn:active { transform: translateY(0); }
422
+
423
+ .secondary-btn:disabled {
424
+ opacity: 0.3;
425
+ cursor: not-allowed;
426
+ transform: none;
427
+ }
428
+
429
+ .btn-icon { width: 20px; height: 20px; fill: currentColor; }
430
+
431
+ /* ── Responsive adjustments ─────────────────── */
432
+ @media (max-width: 600px), (max-height: 700px) {
433
+ .transcript-text { font-size: 13px; line-height: 1.3; }
434
+ .gloss-token { font-size: 11px; padding: 3px 8px; }
435
+ #bottom-panel { padding: 12px 16px 20px; gap: 8px; }
436
+ #text-input-container { max-width: 100%; margin-bottom: 4px; }
437
+ #mic-btn { width: 60px; height: 60px; }
438
+ #waveform { height: 30px; }
439
+ }
440
+ </style>
441
+ </head>
442
+
443
+ <body>
444
+
445
+ <!-- Top Bar -->
446
+ <div id="top-bar">
447
+
448
+ <div style="display: flex; gap: 16px; align-items: center;">
449
+ <div class="logo">SignApp</div>
450
+ <button id="theme-btn" class="secondary-btn" style="width: 36px; height: 36px;" title="Toggle Theme">
451
+ <svg class="btn-icon" viewBox="0 0 24 24" id="theme-icon" style="width: 18px; height: 18px;">
452
+ <path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41.39.39 1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41.39.39 1.03.39 1.41 0l1.06-1.06z"/>
453
+ </svg>
454
+ </button>
455
+ </div>
456
+
457
+ <div class="status-chip">
458
+ <span class="status-dot" id="status-dot"></span>
459
+ <span id="status-text">Ready</span>
460
+ </div>
461
+ </div>
462
+
463
+ <!-- Bottom Panel -->
464
+ <div id="bottom-panel">
465
+
466
+ <!-- Transcript -->
467
+ <div id="transcript-area">
468
+ <div class="transcript-label" id="transcript-label"></div>
469
+ <div class="transcript-text" id="transcript-text"></div>
470
+ <div class="gloss-tokens" id="gloss-tokens"></div>
471
+ </div>
472
+
473
+ <!-- Text Input -->
474
+ <div id="text-input-container">
475
+ <input type="text" id="text-input" placeholder="Or type something here..." autocomplete="off">
476
+ <button id="send-btn">Send</button>
477
+ </div>
478
+
479
+ <!-- Waveform -->
480
+ <div id="waveform"></div>
481
+
482
+ <!-- Sign Progress -->
483
+ <div id="sign-label"></div>
484
+ <div style="font-size: 10px; color: var(--text-muted); opacity: 0.8; text-align: center; max-width: 80%; margin-top: -8px; margin-bottom: 4px;">Note: Generated signs are AI approximations and may not be 100% accurate.</div>
485
+
486
+ <!-- Controls -->
487
+ <div class="controls-row">
488
+ <!-- Replay Button -->
489
+ <button id="replay-btn" class="secondary-btn" title="Replay last signs" disabled>
490
+ <svg class="btn-icon" viewBox="0 0 24 24">
491
+ <path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
492
+ </svg>
493
+ </button>
494
+
495
+ <!-- Microphone Button -->
496
+ <button id="mic-btn" title="Click to speak">
497
+ <svg class="mic-icon" viewBox="0 0 24 24" id="mic-svg">
498
+ <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5z"/>
499
+ <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
500
+ </svg>
501
+ </button>
502
+
503
+ <!-- Placeholder to balance the row if needed, or other future buttons -->
504
+ <div style="width: 48px;"></div>
505
+ </div>
506
+ </div>
507
+
508
+ <script type="module" src="./main.js"></script>
509
+
510
+ </body>
511
+ </html>
src/sign_app/ui/main.js ADDED
@@ -0,0 +1,742 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as THREE from "three"
2
+ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"
3
+ import { playSentence, setCallbacks } from "./signEngine.js"
4
+ import { FINGERSPELL, FINGER_BONE_MAP } from "./fingerspellDictionary.js"
5
+
6
+ /* ═══════════════════════════════════════════════════════════
7
+ GLOBALS
8
+ ═══════════════════════════════════════════════════════════ */
9
+
10
+ window.targetRotations = {}
11
+ window.signing = false
12
+
13
+ let idleTime = 0
14
+ let baseRotations = {}
15
+ let bones = {}
16
+ let avatar
17
+
18
+ /* ─── UI Elements ──────────────────────────────────────── */
19
+ const statusDot = document.getElementById("status-dot")
20
+ const statusText = document.getElementById("status-text")
21
+ const micBtn = document.getElementById("mic-btn")
22
+ const micSvg = document.getElementById("mic-svg")
23
+ const waveformEl = document.getElementById("waveform")
24
+ const signLabel = document.getElementById("sign-label")
25
+ const transcriptLabel = document.getElementById("transcript-label")
26
+ const transcriptText = document.getElementById("transcript-text")
27
+ const glossTokensEl = document.getElementById("gloss-tokens")
28
+
29
+ const replayBtn = document.getElementById("replay-btn")
30
+ const textInput = document.getElementById("text-input")
31
+ const sendBtn = document.getElementById("send-btn")
32
+
33
+ let lastSignSequence = []
34
+
35
+ /* ═══════════════════════════════════════════════════════════
36
+ THREE.JS SCENE
37
+ ═══════════════════════════════════════════════════════════ */
38
+
39
+ const scene = new THREE.Scene()
40
+
41
+ const camera = new THREE.PerspectiveCamera(
42
+ 45,
43
+ window.innerWidth / window.innerHeight,
44
+ 0.1,
45
+ 1000
46
+ )
47
+ camera.position.set(0, 1.25, 2.4)
48
+ camera.lookAt(0, 1.25, 0)
49
+
50
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
51
+ renderer.setSize(window.innerWidth, window.innerHeight)
52
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
53
+ renderer.shadowMap.enabled = true
54
+ renderer.setClearColor(0x000000, 0) // transparent for gradient bg
55
+
56
+ document.body.prepend(renderer.domElement)
57
+ renderer.domElement.id = "avatar-canvas"
58
+
59
+ window.addEventListener("resize", () => {
60
+ camera.aspect = window.innerWidth / window.innerHeight
61
+ camera.updateProjectionMatrix()
62
+ renderer.setSize(window.innerWidth, window.innerHeight)
63
+ })
64
+
65
+ /* ─── Lighting ─────────────────────────────────────────── */
66
+ const keyLight = new THREE.DirectionalLight(0xffffff, 1.2)
67
+ keyLight.position.set(2, 4, 3)
68
+ scene.add(keyLight)
69
+
70
+ const fillLight = new THREE.DirectionalLight(0x93b5ff, 0.5)
71
+ fillLight.position.set(-2, 2, 2)
72
+ scene.add(fillLight)
73
+
74
+ const rimLight = new THREE.DirectionalLight(0xc084fc, 0.4)
75
+ rimLight.position.set(0, 2, -3)
76
+ scene.add(rimLight)
77
+
78
+ const ambient = new THREE.AmbientLight(0xffffff, 0.35)
79
+ scene.add(ambient)
80
+
81
+ const signSpot = new THREE.SpotLight(0xffffff, 1.5)
82
+ signSpot.position.set(0, 3, 2)
83
+ signSpot.target.position.set(0, 1.4, 0)
84
+ scene.add(signSpot)
85
+ scene.add(signSpot.target)
86
+
87
+ /* ─── Avatar ───────────────────────────────────────────── */
88
+ const loader = new GLTFLoader()
89
+
90
+ loader.load("avatar.glb", (gltf) => {
91
+ avatar = gltf.scene
92
+ scene.add(avatar)
93
+
94
+ avatar.scale.set(1.0, 1.0, 1.0)
95
+ avatar.position.set(0, -0.3, 0)
96
+
97
+ avatar.traverse((obj) => {
98
+ if (obj.isBone) {
99
+ bones[obj.name] = obj
100
+ // Save the default rest pose for applying relative offsets
101
+ baseRotations[obj.name] = {
102
+ x: obj.rotation.x,
103
+ y: obj.rotation.y,
104
+ z: obj.rotation.z
105
+ }
106
+ }
107
+ if (obj.isMesh) {
108
+ obj.frustumCulled = false // Fixes meshes (like the face) randomly getting cutoff/disappearing
109
+ if (obj.morphTargetDictionary) {
110
+ if (!window.faceMesh || Object.keys(obj.morphTargetDictionary).length > Object.keys(window.faceMesh.morphTargetDictionary).length) {
111
+ window.faceMesh = obj
112
+ }
113
+ }
114
+ }
115
+ })
116
+
117
+ // Start visible
118
+ avatar.visible = true
119
+ })
120
+
121
+ /* ═══════════════════════════════════════════════════════════
122
+ ANIMATION LOOP
123
+ ═══════════════════════════════════════════════════════════ */
124
+
125
+ function animate() {
126
+ requestAnimationFrame(animate)
127
+ idleTime += 0.03
128
+
129
+ // Lerp all bones toward target rotations + base resting pose
130
+ for (const boneName in window.targetRotations) {
131
+ const bone = bones[boneName]
132
+ const base = baseRotations[boneName]
133
+ if (!bone || !base) continue
134
+
135
+ const targetOffset = window.targetRotations[boneName]
136
+ const speed = 0.15
137
+
138
+ bone.rotation.x = THREE.MathUtils.lerp(bone.rotation.x, base.x + targetOffset.x, speed)
139
+ bone.rotation.y = THREE.MathUtils.lerp(bone.rotation.y, base.y + targetOffset.y, speed)
140
+ bone.rotation.z = THREE.MathUtils.lerp(bone.rotation.z, base.z + targetOffset.z, speed)
141
+ }
142
+
143
+ // Idle breathing
144
+ if (!window.signing && bones.Spine) {
145
+ bones.Spine.rotation.x = Math.sin(idleTime) * 0.015
146
+ }
147
+
148
+ renderer.render(scene, camera)
149
+ }
150
+
151
+ animate()
152
+
153
+ /* ═══════════════════════════════════════════════════════════
154
+ HANDSHAPE SYSTEM (expanded)
155
+ ═══════════════════════════════════════════════════════════ */
156
+
157
+ const HANDSHAPES = {
158
+
159
+ OPEN: {
160
+ RightHandThumb1: { x: 0, y: 0, z: -0.3 },
161
+ RightHandThumb2: { x: 0, y: 0, z: 0 },
162
+ RightHandThumb3: { x: 0, y: 0, z: 0 },
163
+ RightHandIndex1: { x: 0, y: 0, z: 0 }, RightHandIndex2: { x: 0, y: 0, z: 0 }, RightHandIndex3: { x: 0, y: 0, z: 0 },
164
+ RightHandMiddle1: { x: 0, y: 0, z: 0 }, RightHandMiddle2: { x: 0, y: 0, z: 0 }, RightHandMiddle3: { x: 0, y: 0, z: 0 },
165
+ RightHandRing1: { x: 0, y: 0, z: 0 }, RightHandRing2: { x: 0, y: 0, z: 0 }, RightHandRing3: { x: 0, y: 0, z: 0 },
166
+ RightHandPinky1: { x: 0, y: 0, z: 0 }, RightHandPinky2: { x: 0, y: 0, z: 0 }, RightHandPinky3: { x: 0, y: 0, z: 0 },
167
+ RightHand: { x: 0, y: 0, z: 0 },
168
+ },
169
+
170
+ FIST: {
171
+ RightHandThumb1: { x: 0.3, y: 0, z: 0.3 },
172
+ RightHandThumb2: { x: 0.3, y: 0, z: 0 },
173
+ RightHandThumb3: { x: 0.2, y: 0, z: 0 },
174
+ RightHandIndex1: { x: 1.2, y: 0, z: 0 }, RightHandIndex2: { x: 1.2, y: 0, z: 0 }, RightHandIndex3: { x: 1.0, y: 0, z: 0 },
175
+ RightHandMiddle1: { x: 1.2, y: 0, z: 0 }, RightHandMiddle2: { x: 1.2, y: 0, z: 0 }, RightHandMiddle3: { x: 1.0, y: 0, z: 0 },
176
+ RightHandRing1: { x: 1.2, y: 0, z: 0 }, RightHandRing2: { x: 1.2, y: 0, z: 0 }, RightHandRing3: { x: 1.0, y: 0, z: 0 },
177
+ RightHandPinky1: { x: 1.2, y: 0, z: 0 }, RightHandPinky2: { x: 1.2, y: 0, z: 0 }, RightHandPinky3: { x: 1.0, y: 0, z: 0 },
178
+ RightHand: { x: 0, y: 0, z: 0 },
179
+ },
180
+
181
+ POINT: {
182
+ RightHandThumb1: { x: 0.5, y: 0, z: 0.3 },
183
+ RightHandThumb2: { x: 0.4, y: 0, z: 0 },
184
+ RightHandThumb3: { x: 0.2, y: 0, z: 0 },
185
+ RightHandIndex1: { x: 0, y: 0, z: 0 }, RightHandIndex2: { x: 0, y: 0, z: 0 }, RightHandIndex3: { x: 0, y: 0, z: 0 },
186
+ RightHandMiddle1: { x: 1.2, y: 0, z: 0 }, RightHandMiddle2: { x: 1.0, y: 0, z: 0 }, RightHandMiddle3: { x: 0.8, y: 0, z: 0 },
187
+ RightHandRing1: { x: 1.2, y: 0, z: 0 }, RightHandRing2: { x: 1.0, y: 0, z: 0 }, RightHandRing3: { x: 0.8, y: 0, z: 0 },
188
+ RightHandPinky1: { x: 1.2, y: 0, z: 0 }, RightHandPinky2: { x: 1.0, y: 0, z: 0 }, RightHandPinky3: { x: 0.8, y: 0, z: 0 },
189
+ RightHand: { x: 0, y: 0, z: 0 },
190
+ },
191
+
192
+ FLAT: {
193
+ RightHandThumb1: { x: 0.2, y: 0, z: 0.3 },
194
+ RightHandThumb2: { x: 0.1, y: 0, z: 0 },
195
+ RightHandThumb3: { x: 0, y: 0, z: 0 },
196
+ RightHandIndex1: { x: 0, y: 0, z: 0 }, RightHandIndex2: { x: 0, y: 0, z: 0 }, RightHandIndex3: { x: 0, y: 0, z: 0 },
197
+ RightHandMiddle1: { x: 0, y: 0, z: 0 }, RightHandMiddle2: { x: 0, y: 0, z: 0 }, RightHandMiddle3: { x: 0, y: 0, z: 0 },
198
+ RightHandRing1: { x: 0, y: 0, z: 0 }, RightHandRing2: { x: 0, y: 0, z: 0 }, RightHandRing3: { x: 0, y: 0, z: 0 },
199
+ RightHandPinky1: { x: 0, y: 0, z: 0 }, RightHandPinky2: { x: 0, y: 0, z: 0 }, RightHandPinky3: { x: 0, y: 0, z: 0 },
200
+ RightHand: { x: 0, y: 0, z: 0 },
201
+ },
202
+
203
+ CLAW: {
204
+ RightHandThumb1: { x: 0.4, y: 0, z: -0.2 },
205
+ RightHandThumb2: { x: 0.3, y: 0, z: 0 },
206
+ RightHandThumb3: { x: 0.2, y: 0, z: 0 },
207
+ RightHandIndex1: { x: 0.6, y: 0, z: 0 }, RightHandIndex2: { x: 0.5, y: 0, z: 0 }, RightHandIndex3: { x: 0.4, y: 0, z: 0 },
208
+ RightHandMiddle1: { x: 0.6, y: 0, z: 0 }, RightHandMiddle2: { x: 0.5, y: 0, z: 0 }, RightHandMiddle3: { x: 0.4, y: 0, z: 0 },
209
+ RightHandRing1: { x: 0.6, y: 0, z: 0 }, RightHandRing2: { x: 0.5, y: 0, z: 0 }, RightHandRing3: { x: 0.4, y: 0, z: 0 },
210
+ RightHandPinky1: { x: 0.6, y: 0, z: 0 }, RightHandPinky2: { x: 0.5, y: 0, z: 0 }, RightHandPinky3: { x: 0.4, y: 0, z: 0 },
211
+ RightHand: { x: 0, y: 0, z: 0 },
212
+ },
213
+ }
214
+
215
+ function applyHandshape(shape) {
216
+ if (!bones.RightHandIndex1) return
217
+
218
+ const shapeUpper = (shape || "OPEN").toUpperCase()
219
+
220
+ let pose = HANDSHAPES.OPEN // Default
221
+
222
+ // Check built-in shapes first
223
+ if (HANDSHAPES[shapeUpper]) {
224
+ pose = HANDSHAPES[shapeUpper]
225
+ } else if (FINGERSPELL[shapeUpper]) {
226
+ // Check the fingerspell dictionary for letter-based handshapes
227
+ pose = {}
228
+ for (const key in FINGERSPELL[shapeUpper]) {
229
+ const boneName = FINGER_BONE_MAP[key]
230
+ if (boneName) pose[boneName] = FINGERSPELL[shapeUpper][key]
231
+ }
232
+ }
233
+
234
+ for (const bone in pose) {
235
+ window.targetRotations[bone] = { ...pose[bone] }
236
+ }
237
+ }
238
+
239
+ /* ═══════════════════════════════════════════════════════════
240
+ LOCATION SYSTEM (expanded)
241
+ ═══════════════════════════════════════════════════════════ */
242
+
243
+ const LOCATIONS = {
244
+ neutral_space: {
245
+ RightShoulder: { x: 0, y: 0, z: -0.15 },
246
+ RightArm: { x: -0.3, y: 0, z: 0 },
247
+ RightForeArm: { x: -0.4, y: 0.2, z: 0 },
248
+ },
249
+ chest: {
250
+ RightShoulder: { x: 0, y: 0, z: -0.2 },
251
+ RightArm: { x: -0.5, y: 0, z: 0 },
252
+ RightForeArm: { x: -0.6, y: 0.3, z: 0 },
253
+ },
254
+ chin: {
255
+ RightShoulder: { x: 0, y: 0, z: -0.3 },
256
+ RightArm: { x: -0.7, y: 0.1, z: 0 },
257
+ RightForeArm: { x: -0.85, y: 0.3, z: 0 },
258
+ },
259
+ mouth: {
260
+ RightShoulder: { x: 0, y: 0, z: -0.3 },
261
+ RightArm: { x: -0.75, y: 0.1, z: 0 },
262
+ RightForeArm: { x: -0.9, y: 0.3, z: 0 },
263
+ },
264
+ nose: {
265
+ RightShoulder: { x: 0, y: 0, z: -0.32 },
266
+ RightArm: { x: -0.85, y: 0.1, z: 0 },
267
+ RightForeArm: { x: -1.0, y: 0.3, z: 0 },
268
+ },
269
+ forehead: {
270
+ RightShoulder: { x: 0, y: 0, z: -0.35 },
271
+ RightArm: { x: -0.9, y: 0.1, z: 0 },
272
+ RightForeArm: { x: -1.05, y: 0.3, z: 0 },
273
+ },
274
+ temple: {
275
+ RightShoulder: { x: 0, y: 0, z: -0.35 },
276
+ RightArm: { x: -0.85, y: 0.15, z: 0.1 },
277
+ RightForeArm: { x: -1.0, y: 0.35, z: 0 },
278
+ },
279
+ side: {
280
+ RightShoulder: { x: 0, y: 0, z: -0.1 },
281
+ RightArm: { x: -0.3, y: 0, z: 0.3 },
282
+ RightForeArm: { x: -0.4, y: 0.2, z: 0 },
283
+ },
284
+ shoulder: {
285
+ RightShoulder: { x: 0, y: 0, z: -0.15 },
286
+ RightArm: { x: -0.4, y: 0, z: 0.15 },
287
+ RightForeArm: { x: -0.7, y: 0.2, z: 0 },
288
+ },
289
+ ear: {
290
+ RightShoulder: { x: 0, y: 0, z: -0.35 },
291
+ RightArm: { x: -0.85, y: 0.2, z: 0.2 },
292
+ RightForeArm: { x: -1.0, y: 0.35, z: 0 },
293
+ },
294
+ waist: {
295
+ RightShoulder: { x: 0, y: 0, z: -0.1 },
296
+ RightArm: { x: -0.2, y: 0, z: 0 },
297
+ RightForeArm: { x: -0.3, y: 0.15, z: 0 },
298
+ },
299
+ }
300
+
301
+ function applyLocation(loc) {
302
+ if (!bones.RightArm) return
303
+
304
+ const key = (loc || "neutral_space").toLowerCase()
305
+ const pose = LOCATIONS[key] || LOCATIONS.neutral_space
306
+
307
+ for (const bone in pose) {
308
+ if (window.targetRotations) {
309
+ window.targetRotations[bone] = { ...pose[bone] }
310
+ }
311
+ }
312
+ }
313
+
314
+ /* ═══════════════════════════════════════════════════════════
315
+ FINGERSPELLING (from dictionary)
316
+ ═══════════════════════════════════════════════════════════ */
317
+
318
+ function applyFingerSpell(letter) {
319
+ const l = (letter || "").toUpperCase()
320
+ const pose = FINGERSPELL[l]
321
+ if (!pose) return
322
+
323
+ for (const key in pose) {
324
+ const boneName = FINGER_BONE_MAP[key]
325
+ if (boneName) {
326
+ window.targetRotations[boneName] = { ...pose[key] }
327
+ }
328
+ }
329
+
330
+ applyLocation("neutral_space")
331
+ }
332
+
333
+ /* ─── Expose to signEngine ─────────────────────────────── */
334
+ window.applyHandshape = applyHandshape
335
+ window.applyLocation = applyLocation
336
+ window.applyFingerSpell = applyFingerSpell
337
+ window.playSentence = playSentence
338
+
339
+ window.clearPose = function() {
340
+ for (const boneName in window.targetRotations) {
341
+ if (window.targetRotations[boneName]) {
342
+ window.targetRotations[boneName] = { x: 0, y: 0, z: 0 }
343
+ }
344
+ }
345
+ }
346
+
347
+ /* ═══════════════════════════════════════════════════════════
348
+ FACIAL EXPRESSIONS (Morph Targets)
349
+ ═══════════════════════════════════════════════════════════ */
350
+ const EXPRESSIONS = {
351
+ neutral: {},
352
+ happy: { mouthSmile: 1.0 },
353
+ sad: { mouthOpen: 0.1 },
354
+ surprise: { mouthOpen: 0.8, mouthSmile: 0.2 },
355
+ angry: { mouthOpen: 0.3 },
356
+ question: { mouthOpen: 0.2 }
357
+ }
358
+
359
+ window.applyExpression = function(exprName) {
360
+ if (!window.faceMesh || !window.faceMesh.morphTargetDictionary) return
361
+
362
+ const dict = window.faceMesh.morphTargetDictionary
363
+ const influences = window.faceMesh.morphTargetInfluences
364
+
365
+ // Reset all
366
+ for (const key in dict) {
367
+ influences[dict[key]] = 0
368
+ }
369
+
370
+ const expr = EXPRESSIONS[(exprName || "neutral").toLowerCase()]
371
+ if (!expr) return
372
+
373
+ for (const key in expr) {
374
+ let targetIdx = dict[key]
375
+ if (targetIdx === undefined) {
376
+ // Find case-insensitive partial match
377
+ const matchingKey = Object.keys(dict).find(k => k.toLowerCase().includes(key.toLowerCase()))
378
+ if (matchingKey) targetIdx = dict[matchingKey]
379
+ }
380
+
381
+ if (targetIdx !== undefined) {
382
+ influences[targetIdx] = expr[key]
383
+ }
384
+ }
385
+ }
386
+
387
+ /* ═══════════════════════════════════════════════════════════
388
+ SIGN ENGINE CALLBACKS (UI updates)
389
+ ═══════════════════════════════════════════════════════════ */
390
+
391
+ setCallbacks({
392
+ onSignStart: (index, sign) => {
393
+ // Highlight active gloss token
394
+ const tokens = glossTokensEl.querySelectorAll(".gloss-token")
395
+ tokens.forEach((el, i) => {
396
+ el.classList.remove("active")
397
+ if (i < index) el.classList.add("done")
398
+ if (i === index) el.classList.add("active")
399
+ })
400
+
401
+ const label = sign.type === "fingerspell"
402
+ ? `Spelling: ${sign.letter || sign.handshape}`
403
+ : `Signing: ${sign.gloss || sign.handshape}`
404
+
405
+ signLabel.textContent = label
406
+ signLabel.classList.add("visible")
407
+ },
408
+
409
+ onSignEnd: () => {
410
+ signLabel.classList.remove("visible")
411
+ const tokens = glossTokensEl.querySelectorAll(".gloss-token")
412
+ tokens.forEach(el => el.classList.add("done"))
413
+
414
+ setUIState("ready")
415
+ },
416
+
417
+ onLetterStart: (letter) => {
418
+ signLabel.textContent = `Spelling: ${letter}`
419
+ }
420
+ })
421
+
422
+ /* ═══════════════════════════════════════════════════════════
423
+ VOICE RECORDING β€” VAD (Voice Activity Detection)
424
+ ═══════════════════════════════════════════════════════════ */
425
+
426
+ let recorder = null
427
+ let audioChunks = []
428
+ let audioStream = null
429
+ let audioContext = null
430
+ let analyser = null
431
+ let silenceTimer = null
432
+ let isRecording = false
433
+
434
+ const SILENCE_THRESHOLD = 0.015 // RMS threshold for "silence"
435
+ const SILENCE_DURATION = 10000 // ms of silence before auto-send (updated to 10 seconds)
436
+ const MIN_RECORD_TIME = 500 // ms minimum recording
437
+
438
+ // Create waveform bars
439
+ for (let i = 0; i < 24; i++) {
440
+ const bar = document.createElement("div")
441
+ bar.className = "wave-bar"
442
+ waveformEl.appendChild(bar)
443
+ }
444
+ const waveBars = waveformEl.querySelectorAll(".wave-bar")
445
+
446
+ async function startRecording() {
447
+ if (isRecording) {
448
+ // Toggle off β€” stop recording and send
449
+ stopAndSend()
450
+ return
451
+ }
452
+
453
+ try {
454
+ audioStream = await navigator.mediaDevices.getUserMedia({ audio: true })
455
+ } catch (err) {
456
+ console.error("Mic access denied:", err)
457
+ setUIState("ready")
458
+ return
459
+ }
460
+
461
+ isRecording = true
462
+ audioChunks = []
463
+
464
+ // Set up MediaRecorder
465
+ recorder = new MediaRecorder(audioStream)
466
+ recorder.ondataavailable = e => audioChunks.push(e.data)
467
+ recorder.onstop = sendAudioToBackend
468
+ recorder.start()
469
+
470
+ // Set up audio analysis for VAD + waveform
471
+ audioContext = new AudioContext()
472
+ const source = audioContext.createMediaStreamSource(audioStream)
473
+ analyser = audioContext.createAnalyser()
474
+ analyser.fftSize = 256
475
+ source.connect(analyser)
476
+
477
+ setUIState("recording")
478
+ monitorAudio()
479
+ }
480
+
481
+ function monitorAudio() {
482
+ if (!isRecording || !analyser) return
483
+
484
+ const data = new Uint8Array(analyser.frequencyBinCount)
485
+ analyser.getByteTimeDomainData(data)
486
+
487
+ // Calculate RMS
488
+ let sum = 0
489
+ for (let i = 0; i < data.length; i++) {
490
+ const normalized = (data[i] - 128) / 128
491
+ sum += normalized * normalized
492
+ }
493
+ const rms = Math.sqrt(sum / data.length)
494
+
495
+ // Update waveform visual
496
+ updateWaveform(data)
497
+
498
+ // Voice activity detection
499
+ if (rms > SILENCE_THRESHOLD) {
500
+ // Voice detected β€” reset silence timer
501
+ if (silenceTimer) {
502
+ clearTimeout(silenceTimer)
503
+ silenceTimer = null
504
+ }
505
+ } else {
506
+ // Silence β€” start countdown if not already
507
+ if (!silenceTimer && audioChunks.length > 0) {
508
+ silenceTimer = setTimeout(() => {
509
+ if (isRecording) stopAndSend()
510
+ }, SILENCE_DURATION)
511
+ }
512
+ }
513
+
514
+ requestAnimationFrame(monitorAudio)
515
+ }
516
+
517
+ function updateWaveform(data) {
518
+ const step = Math.floor(data.length / waveBars.length)
519
+ waveBars.forEach((bar, i) => {
520
+ const value = Math.abs(data[i * step] - 128) / 128
521
+ const height = Math.max(4, value * 36)
522
+ bar.style.height = `${height}px`
523
+ })
524
+ }
525
+
526
+ function stopAndSend() {
527
+ isRecording = false
528
+ if (silenceTimer) { clearTimeout(silenceTimer); silenceTimer = null }
529
+
530
+ if (recorder && recorder.state === "recording") {
531
+ recorder.stop()
532
+ }
533
+
534
+ if (audioStream) {
535
+ audioStream.getTracks().forEach(t => t.stop())
536
+ audioStream = null
537
+ }
538
+
539
+ if (audioContext) {
540
+ audioContext.close()
541
+ audioContext = null
542
+ }
543
+
544
+ setUIState("processing")
545
+ }
546
+
547
+ /* ═══════════════════════════════════════════════════════════
548
+ SEND TO BACKEND
549
+ ═══════════════════════════════════════════════════════════ */
550
+
551
+ async function sendAudioToBackend() {
552
+ const blob = new Blob(audioChunks, { type: "audio/webm" })
553
+ const formData = new FormData()
554
+ formData.append("file", blob, "recording.webm")
555
+
556
+ try {
557
+ const response = await fetch("/voice-to-text/", {
558
+ method: "POST",
559
+ body: formData
560
+ })
561
+
562
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
563
+
564
+ const data = await response.json()
565
+ console.log("Backend response:", data)
566
+
567
+ displayTranscript(data)
568
+
569
+ if (data.sign_sequence && data.sign_sequence.length > 0) {
570
+ lastSignSequence = data.sign_sequence
571
+ replayBtn.disabled = false
572
+ setUIState("signing")
573
+ if (avatar) avatar.visible = true
574
+ playSentence(data.sign_sequence)
575
+ } else {
576
+ setUIState("ready")
577
+ }
578
+
579
+ } catch (err) {
580
+ console.error("Backend error:", err)
581
+ transcriptLabel.textContent = ""
582
+ transcriptText.textContent = "Connection error β€” is the backend running?"
583
+ transcriptText.classList.add("visible")
584
+ setUIState("ready")
585
+ }
586
+ }
587
+
588
+ /* ═══════════════════════════════════════════════════════════
589
+ UI STATE MANAGEMENT
590
+ ═══════════════════════════════════════════════════════════ */
591
+
592
+ function setUIState(state) {
593
+ micBtn.className = ""
594
+ statusDot.className = "status-dot"
595
+ waveformEl.classList.remove("active")
596
+
597
+ switch (state) {
598
+ case "ready":
599
+ statusText.textContent = "Ready"
600
+ statusDot.className = "status-dot"
601
+ micSvg.innerHTML = '<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>'
602
+ break
603
+
604
+ case "recording":
605
+ statusText.textContent = "Listening..."
606
+ statusDot.className = "status-dot recording"
607
+ micBtn.className = "recording"
608
+ waveformEl.classList.add("active")
609
+ // Stop icon
610
+ micSvg.innerHTML = '<rect x="7" y="7" width="10" height="10" rx="1" fill="white"/>'
611
+ break
612
+
613
+ case "processing":
614
+ statusText.textContent = "Processing..."
615
+ statusDot.className = "status-dot processing"
616
+ micBtn.className = "processing"
617
+ // Spinner icon
618
+ micSvg.innerHTML = '<circle cx="12" cy="12" r="8" stroke="white" stroke-width="2" fill="none" stroke-dasharray="20 30"/>'
619
+ break
620
+
621
+ case "signing":
622
+ statusText.textContent = "Signing..."
623
+ statusDot.className = "status-dot signing"
624
+ break
625
+ }
626
+ }
627
+
628
+ function displayTranscript(data) {
629
+ // Show original transcription
630
+ transcriptLabel.textContent = "TRANSCRIPTION"
631
+ transcriptText.textContent = data.cleaned_transcription || data.raw_transcription || ""
632
+ transcriptText.classList.add("visible")
633
+
634
+ // Show gloss tokens
635
+ glossTokensEl.innerHTML = ""
636
+ if (data.sign_friendly_text && data.sign_friendly_text.length > 0) {
637
+ data.sign_friendly_text.forEach(word => {
638
+ const el = document.createElement("span")
639
+ el.className = "gloss-token"
640
+ el.textContent = word
641
+ glossTokensEl.appendChild(el)
642
+ })
643
+ }
644
+ }
645
+
646
+ /* ─── Mic button handler ───────────────────────────────── */
647
+ micBtn.onclick = startRecording
648
+
649
+ /* ─── Text Input handlers ──────────────────────────────── */
650
+ async function sendTextToBackend() {
651
+ const text = textInput.value.trim()
652
+ if (!text) return
653
+
654
+ textInput.value = ""
655
+ setUIState("processing")
656
+
657
+ try {
658
+ const response = await fetch("/text-to-sign/", {
659
+ method: "POST",
660
+ headers: { "Content-Type": "application/json" },
661
+ body: JSON.stringify({ text })
662
+ })
663
+
664
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
665
+
666
+ const data = await response.json()
667
+ console.log("Text backend response:", data)
668
+
669
+ displayTranscript(data)
670
+
671
+ if (data.sign_sequence && data.sign_sequence.length > 0) {
672
+ lastSignSequence = data.sign_sequence
673
+ replayBtn.disabled = false
674
+ setUIState("signing")
675
+ if (avatar) avatar.visible = true
676
+ playSentence(data.sign_sequence)
677
+ } else {
678
+ setUIState("ready")
679
+ }
680
+
681
+ } catch (err) {
682
+ console.error("Text backend error:", err)
683
+ transcriptLabel.textContent = ""
684
+ transcriptText.textContent = "Connection error β€” is the backend running?"
685
+ transcriptText.classList.add("visible")
686
+ setUIState("ready")
687
+ }
688
+ }
689
+
690
+ sendBtn.onclick = sendTextToBackend
691
+ textInput.onkeydown = (e) => {
692
+ if (e.key === "Enter") sendTextToBackend()
693
+ }
694
+
695
+ /* ─── Replay handler ──────────────────────────────────── */
696
+ replayBtn.onclick = () => {
697
+ if (lastSignSequence.length > 0) {
698
+ // Re-trigger the tokens visualization
699
+ displayTranscript({
700
+ cleaned_transcription: transcriptText.textContent,
701
+ sign_friendly_text: Array.from(glossTokensEl.querySelectorAll(".gloss-token")).map(el => el.textContent),
702
+ sign_sequence: lastSignSequence
703
+ })
704
+
705
+ setUIState("signing")
706
+ playSentence(lastSignSequence)
707
+ }
708
+ }
709
+
710
+ /* ─── Initial state ────────────────────────────────────── */
711
+ setUIState("ready")
712
+ /* ─── Theme Toggle ─────────────────────────────────────── */
713
+ const themeBtn = document.getElementById("theme-btn")
714
+ if (themeBtn) {
715
+ themeBtn.onclick = () => {
716
+ document.body.classList.toggle("light-mode")
717
+ const isLight = document.body.classList.contains("light-mode")
718
+ localStorage.setItem("signapp-theme", isLight ? "light" : "dark")
719
+ updateThemeIcon(isLight)
720
+
721
+ // Also update scene lights to match a bit better in light mode
722
+ if (isLight) {
723
+ scene.background = new THREE.Color(0xf1f5f9)
724
+ } else {
725
+ scene.background = null // transparent
726
+ }
727
+ }
728
+ }
729
+ function updateThemeIcon(isLight) {
730
+ const icon = document.getElementById("theme-icon")
731
+ if (!icon) return
732
+ if (isLight) {
733
+ icon.innerHTML = '<path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-3.03 0-5.5-2.47-5.5-5.5 0-1.82.89-3.42 2.26-4.4C12.92 3.04 12.46 3 12 3zm0 16c-3.86 0-7-3.14-7-7s3.14-7 7-7c.22 0 .44.03.65.08C11.13 6.36 10 7.97 10 9.85c0 2.85 2.3 5.15 5.15 5.15 1.88 0 3.49-1.13 4.77-2.65.05.21.08.43.08.65 0 3.86-3.14 7-7 7z"/>'
734
+ } else {
735
+ icon.innerHTML = '<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41.39.39 1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41.39.39 1.03.39 1.41 0l1.06-1.06z"/>'
736
+ }
737
+ }
738
+ if (localStorage.getItem("signapp-theme") === "light") {
739
+ document.body.classList.add("light-mode")
740
+ updateThemeIcon(true)
741
+ scene.background = new THREE.Color(0xf1f5f9)
742
+ }
src/sign_app/ui/signEngine.js ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FINGERSPELL, FINGER_BONE_MAP } from "./fingerspellDictionary.js"
2
+
3
+ /**
4
+ * Sign Engine β€” orchestrates sign playback, movements, and fingerspelling
5
+ */
6
+
7
+ /* ── State ─────────────────────────────────────────────── */
8
+ let _currentSignIndex = -1
9
+ let _playing = false
10
+
11
+ /* ── Exported callbacks (set by main.js) ───────────────── */
12
+ export let onSignStart = null // (index, sign) => void
13
+ export let onSignEnd = null // () => void
14
+ export let onLetterStart = null // (letter) => void
15
+
16
+ export function setCallbacks({ onSignStart: s, onSignEnd: e, onLetterStart: l }) {
17
+ onSignStart = s
18
+ onSignEnd = e
19
+ onLetterStart = l
20
+ }
21
+
22
+ /* ── Play a full sentence of signs ─────────────────────── */
23
+ export function playSentence(signs) {
24
+ if (_playing) return
25
+ _playing = true
26
+ _currentSignIndex = 0
27
+
28
+ window.signing = true
29
+
30
+ playNext(signs)
31
+ }
32
+
33
+ function playNext(signs) {
34
+ if (_currentSignIndex >= signs.length) {
35
+ // Done β€” return to neutral after a brief hold
36
+ setTimeout(() => {
37
+ returnToNeutral()
38
+ _playing = false
39
+ _currentSignIndex = -1
40
+ window.signing = false
41
+ if (onSignEnd) onSignEnd()
42
+ }, 800)
43
+ return
44
+ }
45
+
46
+ const sign = signs[_currentSignIndex]
47
+
48
+ if (onSignStart) onSignStart(_currentSignIndex, sign)
49
+
50
+ if (sign.type === "fingerspell") {
51
+ playFingerspellLetter(sign, () => {
52
+ _currentSignIndex++
53
+ setTimeout(() => playNext(signs), 250) // short gap between letters
54
+ })
55
+ } else {
56
+ playSign(sign, () => {
57
+ _currentSignIndex++
58
+ setTimeout(() => playNext(signs), 700) // longer gap between signs
59
+ })
60
+ }
61
+ }
62
+
63
+ /* ── Play a single sign (handshape + location + movement + expression) ── */
64
+ function playSign(sign, done) {
65
+ // 1. Apply handshape
66
+ applyHandshapePose(sign.handshape)
67
+
68
+ // 2. Apply location (arm positioning)
69
+ window.applyLocation(sign.location)
70
+
71
+ // 3. Apply expression
72
+ if (window.applyExpression) {
73
+ window.applyExpression(sign.expression || "neutral")
74
+ }
75
+
76
+ // 4. Apply movement animation
77
+ const moveDuration = applyMovement(sign.movement)
78
+
79
+ // Wait for the pose to settle + movement to complete
80
+ setTimeout(() => {
81
+ if (window.applyExpression) window.applyExpression("neutral")
82
+ done()
83
+ }, Math.max(600, moveDuration + 200))
84
+ }
85
+
86
+ /* ── Fingerspell a single letter ───────────────────────── */
87
+ function playFingerspellLetter(sign, done) {
88
+ const letter = (sign.letter || sign.handshape || "").toUpperCase()
89
+
90
+ if (onLetterStart) onLetterStart(letter)
91
+
92
+ const pose = FINGERSPELL[letter]
93
+ if (!pose) {
94
+ done()
95
+ return
96
+ }
97
+
98
+ // Apply full multi-joint fingerspell pose
99
+ for (const key in pose) {
100
+ const boneName = FINGER_BONE_MAP[key]
101
+ if (boneName && window.targetRotations !== undefined) {
102
+ window.targetRotations[boneName] = { ...pose[key] }
103
+ }
104
+ }
105
+
106
+ // Ensure neutral hand position for fingerspelling
107
+ window.applyLocation("neutral_space")
108
+
109
+ setTimeout(done, 450) // hold each letter
110
+ }
111
+
112
+ /* ── Handshape pose application ────────────────────────── */
113
+ function applyHandshapePose(shape) {
114
+ if (!shape) return
115
+
116
+ // Check if this shape exists in FINGERSPELL dictionary first (reuse finger poses)
117
+ const shapeUpper = shape.toUpperCase()
118
+ if (FINGERSPELL[shapeUpper]) {
119
+ const pose = FINGERSPELL[shapeUpper]
120
+ for (const key in pose) {
121
+ const boneName = FINGER_BONE_MAP[key]
122
+ if (boneName && window.targetRotations !== undefined) {
123
+ window.targetRotations[boneName] = { ...pose[key] }
124
+ }
125
+ }
126
+ return
127
+ }
128
+
129
+ // Built-in handshape aliases
130
+ if (window.applyHandshape) {
131
+ window.applyHandshape(shapeUpper)
132
+ }
133
+ }
134
+
135
+ /* ── Movement animations ───────────────────────────────── */
136
+
137
+ /**
138
+ * Apply a movement animation. Returns duration in ms.
139
+ */
140
+ function applyMovement(movement) {
141
+ if (!movement || movement === "none") return 0
142
+
143
+ switch (movement.toLowerCase()) {
144
+
145
+ case "tap": return animateTap()
146
+
147
+ case "double_tap": return animateDoubleTap()
148
+
149
+ case "circle_clockwise": return animateCircle(1)
150
+
151
+ case "circle_counterclockwise": return animateCircle(-1)
152
+
153
+ case "forward": return animateForward()
154
+
155
+ case "pull_in": return animatePullIn()
156
+
157
+ case "down": return animateDown()
158
+
159
+ case "up": return animateUp()
160
+
161
+ case "side_to_side": return animateSideToSide()
162
+
163
+ case "nod": return animateNod()
164
+
165
+ case "twist": return animateTwist()
166
+
167
+ case "wave": return animateWave()
168
+
169
+ case "touch": return animateTouch()
170
+
171
+ default:
172
+ console.log("Unknown movement:", movement)
173
+ return 0
174
+ }
175
+ }
176
+
177
+ /* ── Movement implementations ──────���───────────────────── */
178
+
179
+ function animateTap() {
180
+ const boneR = "RightForeArm"
181
+ const boneL = "LeftForeArm"
182
+ const currentR = getCurrentRotation(boneR)
183
+ const currentL = getCurrentRotation(boneL)
184
+
185
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = { ...currentR, x: currentR.x - 0.15 }
186
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = { ...currentL, x: currentL.x - 0.15 }
187
+
188
+ setTimeout(() => {
189
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = currentR
190
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = currentL
191
+ }, 200)
192
+
193
+ return 400
194
+ }
195
+
196
+ function animateDoubleTap() {
197
+ const boneR = "RightForeArm"
198
+ const boneL = "LeftForeArm"
199
+ const currentR = getCurrentRotation(boneR)
200
+ const currentL = getCurrentRotation(boneL)
201
+
202
+ const applyTap = () => {
203
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = { ...currentR, x: currentR.x - 0.15 }
204
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = { ...currentL, x: currentL.x - 0.15 }
205
+ }
206
+ const applyReset = () => {
207
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = currentR
208
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = currentL
209
+ }
210
+
211
+ applyTap()
212
+ setTimeout(() => {
213
+ applyReset()
214
+ setTimeout(() => {
215
+ applyTap()
216
+ setTimeout(() => applyReset(), 150)
217
+ }, 200)
218
+ }, 150)
219
+
220
+ return 700
221
+ }
222
+
223
+ function animateCircle(direction) {
224
+ const boneR = "RightHand"
225
+ const boneL = "LeftHand"
226
+ const currentR = getCurrentRotation(boneR)
227
+ const currentL = getCurrentRotation(boneL)
228
+ const steps = 8
229
+ const radius = 0.2
230
+ let step = 0
231
+
232
+ const interval = setInterval(() => {
233
+ const angle = (step / steps) * Math.PI * 2 * direction
234
+ if (window.targetRotations[boneR]) {
235
+ window.targetRotations[boneR] = {
236
+ x: currentR.x + Math.sin(angle) * radius,
237
+ y: currentR.y + Math.cos(angle) * radius,
238
+ z: currentR.z
239
+ }
240
+ }
241
+ if (window.targetRotations[boneL]) {
242
+ // Mirror circle on left hand (invert Y)
243
+ window.targetRotations[boneL] = {
244
+ x: currentL.x + Math.sin(angle) * radius,
245
+ y: currentL.y - Math.cos(angle) * radius,
246
+ z: currentL.z
247
+ }
248
+ }
249
+ step++
250
+ if (step >= steps) {
251
+ clearInterval(interval)
252
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = currentR
253
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = currentL
254
+ }
255
+ }, 80)
256
+
257
+ return steps * 80 + 100
258
+ }
259
+
260
+ function animateForward() {
261
+ const boneR = "RightForeArm"
262
+ const boneL = "LeftForeArm"
263
+ const currentR = getCurrentRotation(boneR)
264
+ const currentL = getCurrentRotation(boneL)
265
+
266
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = { ...currentR, x: currentR.x - 0.3 }
267
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = { ...currentL, x: currentL.x - 0.3 }
268
+
269
+ setTimeout(() => {
270
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = currentR
271
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = currentL
272
+ }, 350)
273
+
274
+ return 500
275
+ }
276
+
277
+ function animatePullIn() {
278
+ const boneR = "RightForeArm"
279
+ const boneL = "LeftForeArm"
280
+ const currentR = getCurrentRotation(boneR)
281
+ const currentL = getCurrentRotation(boneL)
282
+
283
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = { ...currentR, x: currentR.x + 0.3 }
284
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = { ...currentL, x: currentL.x + 0.3 }
285
+
286
+ setTimeout(() => {
287
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = currentR
288
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = currentL
289
+ }, 350)
290
+
291
+ return 500
292
+ }
293
+
294
+ function animateDown() {
295
+ const boneR = "RightArm"
296
+ const boneL = "LeftArm"
297
+ const currentR = getCurrentRotation(boneR)
298
+ const currentL = getCurrentRotation(boneL)
299
+
300
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = { ...currentR, x: currentR.x + 0.3 }
301
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = { ...currentL, x: currentL.x + 0.3 }
302
+
303
+ setTimeout(() => {
304
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = currentR
305
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = currentL
306
+ }, 350)
307
+
308
+ return 500
309
+ }
310
+
311
+ function animateUp() {
312
+ const boneR = "RightArm"
313
+ const boneL = "LeftArm"
314
+ const currentR = getCurrentRotation(boneR)
315
+ const currentL = getCurrentRotation(boneL)
316
+
317
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = { ...currentR, x: currentR.x - 0.3 }
318
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = { ...currentL, x: currentL.x - 0.3 }
319
+
320
+ setTimeout(() => {
321
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = currentR
322
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = currentL
323
+ }, 350)
324
+
325
+ return 500
326
+ }
327
+
328
+ function animateSideToSide() {
329
+ const boneR = "RightHand"
330
+ const boneL = "LeftHand"
331
+ const currentR = getCurrentRotation(boneR)
332
+ const currentL = getCurrentRotation(boneL)
333
+
334
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = { ...currentR, z: currentR.z - 0.2 }
335
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = { ...currentL, z: currentL.z + 0.2 }
336
+
337
+ setTimeout(() => {
338
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = { ...currentR, z: currentR.z + 0.2 }
339
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = { ...currentL, z: currentL.z - 0.2 }
340
+ setTimeout(() => {
341
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = currentR
342
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = currentL
343
+ }, 200)
344
+ }, 200)
345
+
346
+ return 600
347
+ }
348
+
349
+ function animateNod() {
350
+ const boneR = "RightHand"
351
+ const boneL = "LeftHand"
352
+ const currentR = getCurrentRotation(boneR)
353
+ const currentL = getCurrentRotation(boneL)
354
+
355
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = { ...currentR, x: currentR.x + 0.25 }
356
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = { ...currentL, x: currentL.x + 0.25 }
357
+
358
+ setTimeout(() => {
359
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = currentR
360
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = currentL
361
+ }, 250)
362
+
363
+ return 450
364
+ }
365
+
366
+ function animateTwist() {
367
+ const boneR = "RightHand"
368
+ const boneL = "LeftHand"
369
+ const currentR = getCurrentRotation(boneR)
370
+ const currentL = getCurrentRotation(boneL)
371
+
372
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = { ...currentR, y: currentR.y + 0.4 }
373
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = { ...currentL, y: currentL.y - 0.4 }
374
+
375
+ setTimeout(() => {
376
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = { ...currentR, y: currentR.y - 0.4 }
377
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = { ...currentL, y: currentL.y + 0.4 }
378
+ setTimeout(() => {
379
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = currentR
380
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = currentL
381
+ }, 200)
382
+ }, 250)
383
+
384
+ return 650
385
+ }
386
+
387
+ function animateWave() {
388
+ const boneR = "RightHand"
389
+ const boneL = "LeftHand"
390
+ const currentR = getCurrentRotation(boneR)
391
+ const currentL = getCurrentRotation(boneL)
392
+ let step = 0
393
+ const steps = 6
394
+
395
+ const interval = setInterval(() => {
396
+ const val = Math.sin(step * 1.2) * 0.2
397
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = { ...currentR, z: currentR.z + val }
398
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = { ...currentL, z: currentL.z - val }
399
+ step++
400
+ if (step >= steps) {
401
+ clearInterval(interval)
402
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = currentR
403
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = currentL
404
+ }
405
+ }, 100)
406
+
407
+ return steps * 100 + 100
408
+ }
409
+
410
+ function animateTouch() {
411
+ const boneR = "RightForeArm"
412
+ const boneL = "LeftForeArm"
413
+ const currentR = getCurrentRotation(boneR)
414
+ const currentL = getCurrentRotation(boneL)
415
+
416
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = { ...currentR, x: currentR.x + 0.15 }
417
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = { ...currentL, x: currentL.x + 0.15 }
418
+
419
+ setTimeout(() => {
420
+ if (window.targetRotations[boneR]) window.targetRotations[boneR] = currentR
421
+ if (window.targetRotations[boneL]) window.targetRotations[boneL] = currentL
422
+ }, 200)
423
+
424
+ return 400
425
+ }
426
+
427
+ /* ── Helpers ───────────────────────────────────────────── */
428
+
429
+ function getCurrentRotation(boneName) {
430
+ if (window.targetRotations && window.targetRotations[boneName]) {
431
+ return { ...window.targetRotations[boneName] }
432
+ }
433
+ return { x: 0, y: 0, z: 0 }
434
+ }
435
+
436
+ function returnToNeutral() {
437
+ if (window.clearPose) window.clearPose()
438
+ }