File size: 19,436 Bytes
e997c0e
 
 
 
 
2342a6c
e997c0e
 
 
 
2342a6c
 
 
e997c0e
 
2342a6c
e997c0e
 
 
2342a6c
 
 
 
e997c0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2342a6c
 
 
 
e997c0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2342a6c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e997c0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2342a6c
25890ff
f8a6123
e997c0e
 
 
 
 
 
2342a6c
 
 
e9f1529
2342a6c
 
e997c0e
 
 
 
 
f8a6123
e997c0e
 
 
 
 
 
 
 
 
f8a6123
e997c0e
 
 
 
2342a6c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e997c0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2342a6c
5cdade1
 
 
 
 
 
 
e997c0e
5cdade1
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
import gradio as gr
import torch
import subprocess
import tempfile
import os
import shutil
import librosa
from typing import Tuple, Optional
from transformers import WhisperProcessor, WhisperForConditionalGeneration
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
from fastapi import FastAPI, File, UploadFile, HTTPException, Query
from fastapi.responses import FileResponse
import uvicorn

# =============================================================================
# Audio Language Translator - Gradio UI + REST API
# =============================================================================
# Pipeline: Whisper (ASR) β†’ NLLB (Translation) β†’ Edge-TTS (Speech Synthesis)
#
# Interfaces:
# - Gradio UI: Interactive web interface for users
# - REST API: Programmatic access for developers
#
# Research Foundation:
# - Radford et al. (2022) "Robust Speech Recognition via Large-Scale Weak Supervision"
# - Costa-jussΓ  et al. (2022) "No Language Left Behind"
# =============================================================================

# ----- Device Setup -----
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Device: {device}")

# ----- Load Whisper -----
print("Loading Whisper...")
whisper_processor = WhisperProcessor.from_pretrained("openai/whisper-small")
whisper_model = WhisperForConditionalGeneration.from_pretrained("openai/whisper-small")
whisper_model = whisper_model.to(device)
whisper_model.eval()
print("βœ… Whisper loaded")

# ----- Load NLLB -----
print("Loading NLLB...")
nllb_tokenizer = AutoTokenizer.from_pretrained("facebook/nllb-200-distilled-600M")
nllb_model = AutoModelForSeq2SeqLM.from_pretrained("facebook/nllb-200-distilled-600M")
nllb_model = nllb_model.to(device)
nllb_model.eval()
print("βœ… NLLB loaded")

# ----- Language Configuration -----
SUPPORTED_LANGUAGES = {
    "en": "English", "es": "Spanish", "fr": "French", "de": "German",
    "zh": "Chinese", "ar": "Arabic", "hi": "Hindi", "ja": "Japanese",
    "ko": "Korean", "pt": "Portuguese", "ru": "Russian", "it": "Italian",
    "nl": "Dutch", "pl": "Polish", "tr": "Turkish"
}

LANG_TO_NLLB = {
    "en": "eng_Latn", "es": "spa_Latn", "fr": "fra_Latn", "de": "deu_Latn",
    "zh": "zho_Hans", "ar": "arb_Arab", "hi": "hin_Deva", "ja": "jpn_Jpan",
    "ko": "kor_Hang", "pt": "por_Latn", "ru": "rus_Cyrl", "it": "ita_Latn",
    "nl": "nld_Latn", "pl": "pol_Latn", "tr": "tur_Latn"
}

TTS_VOICES = {
    "en": {
        "voices": [
            ("en-US-JennyNeural", "Jenny (US, Female)"),
            ("en-US-GuyNeural", "Guy (US, Male)"),
            ("en-GB-SoniaNeural", "Sonia (UK, Female)"),
        ],
        "default": "en-US-JennyNeural"
    },
    "es": {
        "voices": [
            ("es-ES-ElviraNeural", "Elvira (Spain, Female)"),
            ("es-MX-DaliaNeural", "Dalia (Mexico, Female)"),
            ("es-ES-AlvaroNeural", "Alvaro (Spain, Male)"),
        ],
        "default": "es-ES-ElviraNeural"
    },
    "fr": {
        "voices": [
            ("fr-FR-DeniseNeural", "Denise (France, Female)"),
            ("fr-FR-HenriNeural", "Henri (France, Male)"),
            ("fr-CA-SylvieNeural", "Sylvie (Canada, Female)"),
        ],
        "default": "fr-FR-DeniseNeural"
    },
    "de": {
        "voices": [
            ("de-DE-KatjaNeural", "Katja (Female)"),
            ("de-DE-ConradNeural", "Conrad (Male)"),
            ("de-AT-IngridNeural", "Ingrid (Austria, Female)"),
        ],
        "default": "de-DE-KatjaNeural"
    },
    "zh": {
        "voices": [
            ("zh-CN-XiaoxiaoNeural", "Xiaoxiao (Female)"),
            ("zh-CN-YunxiNeural", "Yunxi (Male)"),
            ("zh-CN-XiaoyiNeural", "Xiaoyi (Female)"),
        ],
        "default": "zh-CN-XiaoxiaoNeural"
    },
    "ar": {"voices": [("ar-SA-ZariyahNeural", "Zariyah (Female)")], "default": "ar-SA-ZariyahNeural"},
    "hi": {"voices": [("hi-IN-SwaraNeural", "Swara (Female)")], "default": "hi-IN-SwaraNeural"},
    "ja": {"voices": [("ja-JP-NanamiNeural", "Nanami (Female)")], "default": "ja-JP-NanamiNeural"},
    "ko": {"voices": [("ko-KR-SunHiNeural", "SunHi (Female)")], "default": "ko-KR-SunHiNeural"},
    "pt": {"voices": [("pt-BR-FranciscaNeural", "Francisca (Brazil, Female)")], "default": "pt-BR-FranciscaNeural"},
    "ru": {"voices": [("ru-RU-SvetlanaNeural", "Svetlana (Female)")], "default": "ru-RU-SvetlanaNeural"},
    "it": {"voices": [("it-IT-ElsaNeural", "Elsa (Female)")], "default": "it-IT-ElsaNeural"},
    "nl": {"voices": [("nl-NL-ColetteNeural", "Colette (Female)")], "default": "nl-NL-ColetteNeural"},
    "pl": {"voices": [("pl-PL-AgnieszkaNeural", "Agnieszka (Female)")], "default": "pl-PL-AgnieszkaNeural"},
    "tr": {"voices": [("tr-TR-EmelNeural", "Emel (Female)")], "default": "tr-TR-EmelNeural"},
}

# =============================================================================
# CORE FUNCTIONS (Shared by Gradio and API)
# =============================================================================

def text_to_speech(text: str, lang_code: str, voice: str = None) -> str:
    """Convert text to speech using edge-tts CLI."""
    if lang_code not in TTS_VOICES:
        raise ValueError(f"Unsupported language: {lang_code}")

    if voice is None:
        voice = TTS_VOICES[lang_code]["default"]

    temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".mp3")
    temp_path = temp_file.name
    temp_file.close()

    cmd = ["edge-tts", "--voice", voice, "--text", text, "--write-media", temp_path]
    result = subprocess.run(cmd, capture_output=True, text=True)

    if result.returncode != 0:
        raise RuntimeError(f"TTS failed: {result.stderr}")

    return temp_path


def transcribe_audio(audio_path: str) -> Tuple[str, str]:
    """Transcribe audio using Whisper and detect language."""
    audio, sr = librosa.load(audio_path, sr=16000)

    input_features = whisper_processor(
        audio, sampling_rate=16000, return_tensors="pt"
    ).input_features.to(device)

    with torch.no_grad():
        decoder_input_ids = torch.tensor([[50258]]).to(device)
        outputs = whisper_model(
            input_features,
            decoder_input_ids=decoder_input_ids,
            return_dict=True
        )
        logits = outputs.logits[0, 0]

        lang_tokens = {
            "en": 50259, "zh": 50260, "de": 50261, "es": 50262, "ru": 50263,
            "ko": 50264, "fr": 50265, "ja": 50266, "pt": 50267, "tr": 50268,
            "pl": 50269, "nl": 50271, "ar": 50272, "it": 50274, "hi": 50276
        }
        lang_scores = {lang: logits[token_id].item() for lang, token_id in lang_tokens.items()}
        detected_lang = max(lang_scores, key=lang_scores.get)

    with torch.no_grad():
        predicted_ids = whisper_model.generate(
            input_features,
            language=detected_lang,
            task="transcribe",
            max_new_tokens=440,
        )

    transcription = whisper_processor.batch_decode(predicted_ids, skip_special_tokens=True)[0].strip()
    return transcription, detected_lang


def translate_text(text: str, source_lang: str, target_lang: str) -> str:
    """Translate text using NLLB."""
    if source_lang == target_lang or not text.strip():
        return text

    src_nllb = LANG_TO_NLLB.get(source_lang)
    tgt_nllb = LANG_TO_NLLB.get(target_lang)

    nllb_tokenizer.src_lang = src_nllb
    inputs = nllb_tokenizer(text, return_tensors="pt", max_length=512, truncation=True)
    inputs = {k: v.to(device) for k, v in inputs.items()}

    with torch.no_grad():
        translated_ids = nllb_model.generate(
            **inputs,
            forced_bos_token_id=nllb_tokenizer.convert_tokens_to_ids(tgt_nllb),
            max_new_tokens=512,
            num_beams=5,
            early_stopping=True
        )

    return nllb_tokenizer.batch_decode(translated_ids, skip_special_tokens=True)[0]


def full_pipeline(audio_path: str, target_lang: str, voice: str = None) -> Tuple[str, str, str, str, str]:
    """Complete audio translation pipeline."""
    try:
        transcription, detected_lang = transcribe_audio(audio_path)
        detected_lang_name = SUPPORTED_LANGUAGES.get(detected_lang, detected_lang)

        if not transcription.strip():
            return detected_lang_name, "(No speech detected)", "", None, "⚠️ No speech detected"

        target_lang_name = SUPPORTED_LANGUAGES.get(target_lang, target_lang)

        if detected_lang == target_lang:
            translated_text = transcription
        else:
            translated_text = translate_text(transcription, detected_lang, target_lang)

        output_audio = text_to_speech(translated_text, target_lang, voice)
        status = f"βœ… Detected: {detected_lang_name} β†’ Output: {target_lang_name}"

        return detected_lang_name, transcription, translated_text, output_audio, status
    except Exception as e:
        import traceback
        traceback.print_exc()
        return "Error", "", "", None, f"❌ Error: {str(e)}"


# =============================================================================
# REST API ENDPOINTS
# =============================================================================

# Create FastAPI app for API endpoints
api_app = FastAPI(
    title="Audio Language Translator API",
    description="""
    REST API for translating spoken audio between 15 languages.
    
    **Pipeline:** Whisper (ASR) β†’ NLLB (Translation) β†’ Edge-TTS (Speech Synthesis)
    
    **Endpoints:**
    - `GET /api/languages` - List supported languages
    - `GET /api/voices/{lang}` - Get available voices for a language
    - `POST /api/transcribe` - Transcribe audio (no translation)
    - `POST /api/translate` - Full translation pipeline
    - `GET /api/health` - Health check
    
    **Research Foundation:**
    - [Whisper](https://arxiv.org/abs/2212.04356) (Radford et al., 2022)
    - [NLLB](https://arxiv.org/abs/2207.04672) (Costa-jussΓ  et al., 2022)
    """,
    version="1.0.0"
)

@api_app.get("/api/health")
def health_check():
    """Check API health and model status."""
    return {
        "status": "healthy",
        "device": str(device),
        "models_loaded": True
    }

@api_app.get("/api/languages")
def get_languages():
    """Get list of supported languages."""
    return {
        "languages": [
            {"code": code, "name": name} 
            for code, name in SUPPORTED_LANGUAGES.items()
        ],
        "total": len(SUPPORTED_LANGUAGES)
    }

@api_app.get("/api/voices/{lang_code}")
def get_voices(lang_code: str):
    """Get available TTS voices for a language."""
    if lang_code not in TTS_VOICES:
        raise HTTPException(status_code=404, detail=f"Language '{lang_code}' not supported")
    
    voices = TTS_VOICES[lang_code]
    return {
        "language": lang_code,
        "language_name": SUPPORTED_LANGUAGES.get(lang_code, lang_code),
        "voices": [{"id": v[0], "name": v[1]} for v in voices["voices"]],
        "default": voices["default"]
    }

@api_app.post("/api/transcribe")
async def api_transcribe(file: UploadFile = File(...)):
    """Transcribe audio and detect language (no translation)."""
    # Save uploaded file
    with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
        shutil.copyfileobj(file.file, tmp)
        tmp_path = tmp.name
    
    try:
        transcription, detected_lang = transcribe_audio(tmp_path)
        return {
            "transcription": transcription,
            "detected_language": detected_lang,
            "detected_language_name": SUPPORTED_LANGUAGES.get(detected_lang, detected_lang)
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))
    finally:
        os.unlink(tmp_path)

@api_app.post("/api/translate")
async def api_translate(
    file: UploadFile = File(...),
    target_language: str = Query(..., description="Target language code (e.g., 'es', 'fr', 'de')"),
    voice: Optional[str] = Query(None, description="TTS voice ID (optional)")
):
    """
    Full translation pipeline: transcribe β†’ translate β†’ text-to-speech.
    
    Returns JSON with text results. Use /api/translate/audio to get audio file.
    """
    if target_language not in SUPPORTED_LANGUAGES:
        raise HTTPException(
            status_code=400, 
            detail=f"Unsupported target language: {target_language}. Supported: {list(SUPPORTED_LANGUAGES.keys())}"
        )
    
    # Save uploaded file
    with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
        shutil.copyfileobj(file.file, tmp)
        input_path = tmp.name
    
    try:
        # Run pipeline
        detected_lang_name, transcription, translated_text, output_audio, status = full_pipeline(
            input_path, target_language, voice
        )
        
        return {
            "original_text": transcription,
            "detected_language": detected_lang_name,
            "translated_text": translated_text,
            "target_language": SUPPORTED_LANGUAGES.get(target_language, target_language),
            "target_language_code": target_language,
            "audio_generated": output_audio is not None,
            "status": status
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))
    finally:
        os.unlink(input_path)

@api_app.post("/api/translate/audio")
async def api_translate_audio(
    file: UploadFile = File(...),
    target_language: str = Query(..., description="Target language code"),
    voice: Optional[str] = Query(None, description="TTS voice ID (optional)")
):
    """Full translation pipeline - returns audio file directly."""
    if target_language not in SUPPORTED_LANGUAGES:
        raise HTTPException(status_code=400, detail=f"Unsupported language: {target_language}")
    
    with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
        shutil.copyfileobj(file.file, tmp)
        input_path = tmp.name
    
    try:
        _, _, _, output_audio, _ = full_pipeline(input_path, target_language, voice)
        
        if output_audio is None:
            raise HTTPException(status_code=500, detail="Failed to generate audio")
        
        return FileResponse(
            output_audio,
            media_type="audio/mpeg",
            filename=f"translated_{target_language}.mp3"
        )
    finally:
        os.unlink(input_path)


# =============================================================================
# GRADIO INTERFACE
# =============================================================================

def get_voice_id(lang_code: str, voice_name: str) -> str:
    if lang_code in TTS_VOICES:
        for vid, vname in TTS_VOICES[lang_code]["voices"]:
            if vname == voice_name:
                return vid
    return TTS_VOICES[lang_code]["default"]

def update_voices(lang: str):
    voices = [v[1] for v in TTS_VOICES[lang]["voices"]]
    return gr.Dropdown(choices=voices, value=voices[0])

def process(audio, target_lang, voice_name):
    if audio is None:
        return "⚠️ Upload or record audio first.", "", "", None

    voice_id = get_voice_id(target_lang, voice_name)
    detected, original, translated, output_audio, status = full_pipeline(audio, target_lang, voice_id)

    return f"**Detected:** {detected}\n\n**Status:** {status}", original, translated, output_audio

lang_choices = [(name, code) for code, name in SUPPORTED_LANGUAGES.items()]

# Create Gradio interface

with gr.Blocks(title="Audio Language Translator") as demo:
    gr.Markdown("""
    # 🌍 Audio Language Translator

    Translate spoken audio between 15 languages using AI.

    **Pipeline:** Whisper (ASR) β†’ NLLB (Translation) β†’ Edge-TTS (Speech Synthesis)
    
    ---
    
    **πŸ”Œ REST API Available!** [View API Documentation](/docs)
    
    ---
    """)

    with gr.Row():
        with gr.Column():
            gr.Markdown("### 🎀 Input")
            audio_in = gr.Audio(label="Upload or Record", type="filepath", sources=["upload", "microphone"])
            target = gr.Dropdown(label="Target Language", choices=lang_choices, value="es")
            voice = gr.Dropdown(label="Voice", choices=[v[1] for v in TTS_VOICES["es"]["voices"]], value=TTS_VOICES["es"]["voices"][0][1])
            btn = gr.Button("πŸ”„ Translate", variant="primary")

        with gr.Column():
            gr.Markdown("### πŸ“ Output")
            status_out = gr.Markdown()
            original_out = gr.Textbox(label="Original Transcription", lines=3)
            translated_out = gr.Textbox(label="Translated Text", lines=3)
            audio_out = gr.Audio(label="Translated Audio", type="filepath")

    target.change(update_voices, target, voice)
    btn.click(process, [audio_in, target, voice], [status_out, original_out, translated_out, audio_out])

    with gr.Accordion("πŸ”Œ REST API Documentation", open=False):
        gr.Markdown("""
        ### API Endpoints
        
        Access the interactive API documentation at **`/api/docs`**
        
        | Endpoint | Method | Description |
        |----------|--------|-------------|
        | `/api/health` | GET | Health check |
        | `/api/languages` | GET | List supported languages |
        | `/api/voices/{lang}` | GET | Get voices for a language |
        | `/api/transcribe` | POST | Transcribe audio only |
        | `/api/translate` | POST | Full translation (returns JSON) |
        | `/api/translate/audio` | POST | Full translation (returns audio file) |
        
        ### Example Usage (Python)
```python
        import requests
        
        # Translate audio file
        with open("input.wav", "rb") as f:
            response = requests.post(
                "https://your-space.hf.space/api/translate",
                files={"file": f},
                params={"target_language": "es"}
            )
        print(response.json())
```
        
        ### Example Usage (cURL)
```bash
        curl -X POST "https://your-space.hf.space/api/translate" \
             -F "file=@input.wav" \
             -F "target_language=es"
```
        """)

    with gr.Accordion("πŸ“š Supported Languages & Voices", open=False):
        gr.Markdown("""
        **Tier 1 (Multiple Voices):** English (3), Spanish (3), French (3), German (3), Chinese (3)

        **Tier 2 (Single Voice):** Arabic, Hindi, Japanese, Korean, Portuguese, Russian, Italian, Dutch, Polish, Turkish

        **Total:** 15 languages, 25 voices
        """)

    with gr.Accordion("πŸ”§ Technical Details", open=False):
        gr.Markdown("""
        | Component | Model | Parameters | Purpose |
        |-----------|-------|------------|---------|
        | ASR | openai/whisper-small | 244M | Speech-to-text with language detection |
        | Translation | facebook/nllb-200-distilled-600M | 615M | Multilingual translation (200 languages) |
        | TTS | Microsoft Edge-TTS | API | Neural text-to-speech (25 voices) |

        **GPU Memory:** ~3.5 GB (Whisper + NLLB)
        """)

# Mount FastAPI to Gradio
# Mount Gradio onto FastAPI
app = gr.mount_gradio_app(api_app, demo, path="/")

# HuggingFace Spaces runs app.py directly, not via __main__
# So we need to use uvicorn for both local and HF deployment
import uvicorn

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=7860)
else:
    # For HuggingFace Spaces - it imports the app directly
    # The 'app' variable is already set above
    pass