michaellin's picture
Create app.py
ef21a59 verified
Raw
History Blame Contribute Delete
9.77 kB
"""
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()