Spaces:
Sleeping
Sleeping
| """ | |
| Morse Code Demo β Gradio app | |
| Tab 1: Text β Morse audio | |
| Tab 2: Morse audio β transcription + English translation | |
| """ | |
| import math | |
| import tempfile | |
| import gradio as gr | |
| import librosa | |
| import numpy as np | |
| import soundfile as sf | |
| import torch | |
| from transformers import WhisperForConditionalGeneration, WhisperProcessor | |
| # ββ Morse table ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| MORSE_TABLE = { | |
| "A": ".-", | |
| "B": "-...", | |
| "C": "-.-.", | |
| "D": "-..", | |
| "E": ".", | |
| "F": "..-.", | |
| "G": "--.", | |
| "H": "....", | |
| "I": "..", | |
| "J": ".---", | |
| "K": "-.-", | |
| "L": ".-..", | |
| "M": "--", | |
| "N": "-.", | |
| "O": "---", | |
| "P": ".--.", | |
| "Q": "--.-", | |
| "R": ".-.", | |
| "S": "...", | |
| "T": "-", | |
| "U": "..-", | |
| "V": "...-", | |
| "W": ".--", | |
| "X": "-..-", | |
| "Y": "-.--", | |
| "Z": "--..", | |
| "0": "-----", | |
| "1": ".----", | |
| "2": "..---", | |
| "3": "...--", | |
| "4": "....-", | |
| "5": ".....", | |
| "6": "-....", | |
| "7": "--...", | |
| "8": "---..", | |
| "9": "----.", | |
| ".": ".-.-.-", | |
| ",": "--..--", | |
| "?": "..--..", | |
| "'": ".----.", | |
| "!": "-.-.--", | |
| "/": "-..-.", | |
| "(": "-.--.", | |
| ")": "-.--.-", | |
| } | |
| SAMPLE_RATE = 16_000 | |
| RISE_TIME_MS = 8 | |
| PAD_MS = 300 | |
| WPM_DEFAULT = 20 | |
| WPM_MAX = 60 | |
| MAX_DURATION = 30.0 | |
| SNR_DB = 15.0 | |
| def _ms(ms: float) -> int: | |
| return int(round(ms * SAMPLE_RATE / 1000)) | |
| def _tone(duration_ms: float, freq: float) -> np.ndarray: | |
| n = _ms(duration_ms) | |
| t = np.arange(n) / SAMPLE_RATE | |
| sig = np.sin(2 * np.pi * freq * t).astype(np.float32) | |
| rise = min(_ms(RISE_TIME_MS), n // 2) | |
| if rise > 0: | |
| w = np.ones(n, dtype=np.float32) | |
| h = np.hanning(2 * rise)[:rise] | |
| w[:rise], w[-rise:] = h, h[::-1] | |
| sig *= w | |
| return sig | |
| def _silence(ms: float) -> np.ndarray: | |
| return np.zeros(_ms(ms), dtype=np.float32) | |
| def _count_units(text: str) -> int: | |
| units = 0 | |
| for w_idx, word in enumerate(text.upper().split()): | |
| if w_idx > 0: | |
| units += 7 | |
| chars = [c for c in word if c in MORSE_TABLE] | |
| for c_idx, ch in enumerate(chars): | |
| if c_idx > 0: | |
| units += 3 | |
| for e_idx, e in enumerate(MORSE_TABLE[ch]): | |
| if e_idx > 0: | |
| units += 1 | |
| units += 1 if e == "." else 3 | |
| return units | |
| def _choose_wpm(text: str) -> int: | |
| units = _count_units(text) | |
| if units == 0: | |
| return WPM_DEFAULT | |
| max_dit = (MAX_DURATION - 2 * PAD_MS / 1000) * 1000 / units | |
| return max( | |
| WPM_DEFAULT, min(math.ceil(1200 / max_dit) if max_dit > 0 else WPM_MAX, WPM_MAX) | |
| ) | |
| def text_to_audio(text: str, freq: float = 650.0, wpm: int | None = None) -> np.ndarray: | |
| if wpm is None: | |
| wpm = _choose_wpm(text) | |
| dit_ms = 1200 / wpm | |
| dash_ms = 3 * dit_ms | |
| dit = _tone(dit_ms, freq) | |
| dash = _tone(dash_ms, freq) | |
| chunks = [_silence(PAD_MS)] | |
| for w_idx, word in enumerate(text.upper().split()): | |
| if w_idx > 0: | |
| chunks.append(_silence(7 * dit_ms)) | |
| chars = [c for c in word if c in MORSE_TABLE] | |
| for c_idx, ch in enumerate(chars): | |
| if c_idx > 0: | |
| chunks.append(_silence(3 * dit_ms)) | |
| for e_idx, e in enumerate(MORSE_TABLE[ch]): | |
| if e_idx > 0: | |
| chunks.append(_silence(dit_ms)) | |
| chunks.append(dit if e == "." else dash) | |
| chunks.append(_silence(PAD_MS)) | |
| audio = np.concatenate(chunks) | |
| peak = np.max(np.abs(audio)) | |
| if peak > 0: | |
| audio /= peak | |
| # add mild noise | |
| p = np.mean(audio**2) | |
| if p > 0: | |
| std = math.sqrt(p / (10 ** (SNR_DB / 10))) | |
| audio = np.clip( | |
| audio + np.random.normal(0, std, audio.shape).astype(np.float32), -1, 1 | |
| ) | |
| return audio | |
| # ββ Whisper model (loaded once) ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Path to fine-tuned HF checkpoint directory; set to a base model id (e.g. | |
| # "openai/whisper-small") to fall back to the unmodified base model. | |
| FINETUNED_CHECKPOINT: str = "michaellin/whisper-small-morse" | |
| _processor: WhisperProcessor | None = None | |
| _model: WhisperForConditionalGeneration | None = None | |
| _device: torch.device | None = None | |
| def _get_whisper() -> ( | |
| tuple[WhisperProcessor, WhisperForConditionalGeneration, torch.device] | |
| ): | |
| global _processor, _model, _device | |
| if _model is None: | |
| _device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| _processor = WhisperProcessor.from_pretrained(FINETUNED_CHECKPOINT) | |
| _model = WhisperForConditionalGeneration.from_pretrained(FINETUNED_CHECKPOINT) | |
| _model.to(_device) | |
| _model.eval() | |
| return _processor, _model, _device | |
| def _forced_decoder_ids( | |
| processor: WhisperProcessor, task: str | |
| ) -> list[tuple[int, int]]: | |
| """ | |
| Build forced_decoder_ids for the given task ("transcribe" or "translate"). | |
| Uses <|startoflm|> as the language token when available (fine-tuned model); | |
| otherwise falls back to English. | |
| """ | |
| tokenizer = processor.tokenizer | |
| morse_id = tokenizer.convert_tokens_to_ids("<|startoflm|>") | |
| unk_id = ( | |
| tokenizer.convert_tokens_to_ids(tokenizer.unk_token) | |
| if tokenizer.unk_token | |
| else None | |
| ) | |
| if morse_id is None or morse_id == unk_id: | |
| # Stock base model β no morse token, fall back to English | |
| return processor.get_decoder_prompt_ids(language="en", task=task) | |
| task_id = tokenizer.convert_tokens_to_ids(f"<|{task}|>") | |
| notimestamps_id = tokenizer.convert_tokens_to_ids("<|notimestamps|>") | |
| # HF Whisper prefix: position 1 = language, 2 = task, 3 = (no)timestamps | |
| return [(1, morse_id), (2, task_id), (3, notimestamps_id)] | |
| def _run_whisper(audio_path: str, task: str) -> str: | |
| processor, model, device = _get_whisper() | |
| audio, _ = librosa.load(audio_path, sr=SAMPLE_RATE, mono=True) | |
| inputs = processor(audio, sampling_rate=SAMPLE_RATE, return_tensors="pt") | |
| input_features = inputs.input_features.to(device) | |
| forced = _forced_decoder_ids(processor, task) | |
| with torch.no_grad(): | |
| pred_ids = model.generate(input_features, forced_decoder_ids=forced) | |
| return processor.batch_decode(pred_ids, skip_special_tokens=True)[0].strip() | |
| # ββ Gradio handlers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def generate_morse(text: str, freq: float, wpm: int): | |
| if not text.strip(): | |
| return None, "Please enter some text." | |
| audio = text_to_audio(text.strip(), freq=freq, wpm=wpm if wpm > 0 else None) | |
| with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: | |
| sf.write(f.name, audio, SAMPLE_RATE, subtype="PCM_16") | |
| path = f.name | |
| morse_str = " / ".join( | |
| " ".join(MORSE_TABLE.get(c, "") for c in w.upper() if c in MORSE_TABLE) | |
| for w in text.split() | |
| ) | |
| return path, f"Morse: {morse_str}" | |
| def decode_morse(audio_path: str): | |
| if audio_path is None: | |
| return "Upload a WAV file first.", "" | |
| transcription = _run_whisper(audio_path, task="transcribe") | |
| translation = _run_whisper(audio_path, task="translate") | |
| return transcription, translation | |
| # ββ UI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| WEBSDR_MD = """ | |
| ## Live Radio Demo (WebSDR) | |
| 1. Open **[websdr.ewi.utwente.nl:8901](http://websdr.ewi.utwente.nl:8901/m.html)** in your browser | |
| 2. Tune to **~7000 kHz**, switch mode to **CW** | |
| 3. Drag the trapezoid shape onto a signal line β you'll hear Morse code | |
| 4. Use your browser's audio capture or any screen-recorder to save a WAV clip | |
| 5. Upload the clip to the **Decode** tab above | |
| > Once the model is fine-tuned with a `<|startoflm|>` language token, | |
| > decoding will use that token instead of falling back to English. | |
| """ | |
| with gr.Blocks(title="Morse Code Demo") as demo: | |
| gr.Markdown("# Morse Code Demo") | |
| with gr.Tab("Generate: Text β Morse Audio"): | |
| txt_in = gr.Textbox(label="Input text", placeholder="HELLO WORLD") | |
| freq_sl = gr.Slider(400, 1000, value=650, step=50, label="Tone frequency (Hz)") | |
| wpm_sl = gr.Slider(0, 60, value=20, step=5, label="Speed (WPM) β 0 = auto") | |
| gen_btn = gr.Button("Generate") | |
| audio_out = gr.Audio(label="Morse audio", type="filepath") | |
| morse_out = gr.Textbox(label="Morse code") | |
| with gr.Tab("Decode: Morse Audio β Text"): | |
| audio_in = gr.Audio(label="Morse audio (WAV)", type="filepath") | |
| dec_btn = gr.Button("Decode") | |
| transcription_out = gr.Textbox(label="Transcription") | |
| translation_out = gr.Textbox(label="Translation") | |
| dec_btn.click(decode_morse, [audio_in], [transcription_out, translation_out]) | |
| # TODO: This should eventually run live as a browser extension. Disabling for pilot study demo. | |
| #with gr.Tab("Live Radio (WebSDR)"): | |
| # gr.Markdown(WEBSDR_MD) | |
| # Wire up generate β decode tab. The Generate button populates audio_out, | |
| # which then propagates to audio_in via .change(). The user can still | |
| # manually upload or clear audio_in independently. | |
| gen_btn.click(generate_morse, [txt_in, freq_sl, wpm_sl], [audio_out, morse_out]) | |
| audio_out.change(lambda x: x, [audio_out], [audio_in]) | |
| if __name__ == "__main__": | |
| demo.launch() | |