我最近构建了一个功能齐全的摩尔斯电码翻译器(摩尔斯电码翻译器),并学习了一些关于音频生成、编码算法以及 Next.js 15 新功能的有趣经验。这篇文章将详细分析我遇到的技术实现和挑战。
为什么 2025 年还要用摩尔斯电码? 在深入研究代码之前,您可能会想:为什么要在 2025 年构建摩尔斯电码工具?
除了怀旧因素之外,摩尔斯电码实际上是可变长度编码中一个引人入胜的案例。常用字母的代码更短(E =“.”,T =“-”),这后来启发了现代压缩中使用的霍夫曼编码算法。此外,它仍然活跃地应用于:
业余无线电(火腿电台)通信 航空识别信标 残疾人辅助技术 现代系统出现故障时的紧急通信 技术栈 Next.js 15(应用路由器 + Turbopack) React 19(服务器组件) TypeScript(严格模式) Tailwind CSS 4(预发布) Web Audio API(用于声音生成) 摩尔斯电码编码逻辑 构建翻译图 任何摩尔斯电码翻译器的核心都是字符到代码的映射。其简洁的数据结构如下:
// lib/morse.ts export const MORSE_CODE_MAP: Record<string, string> = { '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': '----.', '.': '.-.-.-', ',': '--..--', '?': '..--..', '/': '-..-.', ' ': '/' };
// Create reverse map for decoding export const MORSE_TO_TEXT_MAP = Object.entries(MORSE_CODE_MAP) .reduce((acc, [char, morse]) => { acc[morse] = char; return acc; }, {} as Record<string, string>); 文本到摩尔斯电码的转换 编码函数处理边缘情况并保留字间距:
export function textToMorse(text: string): string { return text .toUpperCase() .split('') .map(char => { if (char === ' ') return '/'; // Word separator return MORSE_CODE_MAP[char] || ''; }) .filter(Boolean) .join(' '); // Letter separator } 莫尔斯电码到文本的转换 解码比较棘手,因为我们需要处理单词边界:
export function morseToText(morse: string): string { return morse .split(' / ') // Split by word separator .map(word => word .split(' ') // Split by letter separator .map(code => MORSE_TO_TEXT_MAP[code] || '') .join('') ) .join(' '); } 使用 Web Audio API 生成莫尔斯音频 这是最具挑战性的部分。Web Audio API 功能强大,但在不同浏览器之间却存在一些问题。
音频上下文设置 // utils/audioGenerator.ts export class MorseAudioGenerator { private audioContext: AudioContext; private frequency: number = 600; // Hz private dotDuration: number = 0.06; // seconds
constructor(wpm: number = 20) { this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
// Calculate timing based on words per minute
// Standard: "PARIS " = 50 dot durations
this.dotDuration = 1.2 / wpm;
}
private get dashDuration() { return this.dotDuration * 3; }
private get symbolGap() { return this.dotDuration; }
private get letterGap() { return this.dotDuration * 3; }
private get wordGap() { return this.dotDuration * 7; }
playTone(duration: number, startTime: number): void { const oscillator = this.audioContext.createOscillator(); const gainNode = this.audioContext.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = this.frequency;
// Smooth attack and release to prevent clicks
gainNode.gain.setValueAtTime(0, startTime);
gainNode.gain.linearRampToValueAtTime(0.3, startTime + 0.005);
gainNode.gain.setValueAtTime(0.3, startTime + duration - 0.005);
gainNode.gain.linearRampToValueAtTime(0, startTime + duration);
oscillator.connect(gainNode);
gainNode.connect(this.audioContext.destination);
oscillator.start(startTime);
oscillator.stop(startTime + duration);
}
async playMorse(morseCode: string): Promise { let currentTime = this.audioContext.currentTime;
for (let i = 0; i < morseCode.length; i++) {
const symbol = morseCode[i];
if (symbol === '.') {
this.playTone(this.dotDuration, currentTime);
currentTime += this.dotDuration + this.symbolGap;
} else if (symbol === '-') {
this.playTone(this.dashDuration, currentTime);
currentTime += this.dashDuration + this.symbolGap;
} else if (symbol === ' ') {
currentTime += this.letterGap;
} else if (symbol === '/') {
currentTime += this.wordGap;
}
}
// Wait for playback to complete
const totalDuration = (currentTime - this.audioContext.currentTime) * 1000;
await new Promise(resolve => setTimeout(resolve, totalDuration));
}
generateWAV(morseCode: string): Blob { const sampleRate = 44100; const samples: Float32Array[] = [];
for (const symbol of morseCode) {
if (symbol === '.') {
samples.push(this.generateTone(this.dotDuration, sampleRate));
samples.push(this.generateSilence(this.symbolGap, sampleRate));
} else if (symbol === '-') {
samples.push(this.generateTone(this.dashDuration, sampleRate));
samples.push(this.generateSilence(this.symbolGap, sampleRate));
} else if (symbol === ' ') {
samples.push(this.generateSilence(this.letterGap, sampleRate));
} else if (symbol === '/') {
samples.push(this.generateSilence(this.wordGap, sampleRate));
}
}
// Combine all samples
const totalLength = samples.reduce((sum, arr) => sum + arr.length, 0);
const combinedSamples = new Float32Array(totalLength);
let offset = 0;
for (const sample of samples) {
combinedSamples.set(sample, offset);
offset += sample.length;
}
return this.createWavBlob(combinedSamples, sampleRate);
}
private generateTone(duration: number, sampleRate: number): Float32Array { const samples = Math.floor(duration * sampleRate); const buffer = new Float32Array(samples);
for (let i = 0; i < samples; i++) {
// Apply envelope to prevent clicks
const envelope = this.getEnvelope(i, samples);
buffer[i] = Math.sin(2 * Math.PI * this.frequency * i / sampleRate) *
envelope * 0.3;
}
return buffer;
}
private generateSilence(duration: number, sampleRate: number): Float32Array { return new Float32Array(Math.floor(duration * sampleRate)); }
private getEnvelope(sample: number, totalSamples: number): number { const attackSamples = Math.floor(0.005 * 44100); const releaseSamples = Math.floor(0.005 * 44100);
if (sample < attackSamples) {
return sample / attackSamples;
} else if (sample > totalSamples - releaseSamples) {
return (totalSamples - sample) / releaseSamples;
}
return 1;
}
private createWavBlob(samples: Float32Array, sampleRate: number): Blob { const buffer = new ArrayBuffer(44 + samples.length * 2); const view = new DataView(buffer);
// WAV header
this.writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + samples.length * 2, true);
this.writeString(view, 8, 'WAVE');
this.writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
this.writeString(view, 36, 'data');
view.setUint32(40, samples.length * 2, true);
// PCM samples
let offset = 44;
for (let i = 0; i < samples.length; i++, offset += 2) {
const sample = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(offset, sample * 0x7FFF, true);
}
return new Blob([buffer], { type: 'audio/wav' });
}
private writeString(view: DataView, offset: number, string: string): void { for (let i = 0; i < string.length; i++) { view.setUint8(offset + i, string.charCodeAt(i)); } } } React 组件实现 这是使用 React 19 hooks 的主要翻译器组件:
'use client';
import { useState, useCallback, useMemo } from 'react'; import { textToMorse, morseToText } from '@/lib/morse'; import { MorseAudioGenerator } from '@/utils/audioGenerator';
export default function MorseTranslator() { const [inputText, setInputText] = useState(''); const [isTextToMorse, setIsTextToMorse] = useState(true); const [isPlaying, setIsPlaying] = useState(false); const [wpm, setWpm] = useState(20);
const morseCode = useMemo(() => { return isTextToMorse ? textToMorse(inputText) : inputText; }, [inputText, isTextToMorse]);
const handleSwapDirection = () => { setIsTextToMorse(!isTextToMorse); setInputText(''); };
const handlePlayAudio = async () => { if (isPlaying || !morseCode) return;
setIsPlaying(true);
const generator = new MorseAudioGenerator(wpm);
try {
await generator.playMorse(morseCode);
} catch (error) {
console.error('Audio playback failed:', error);
} finally {
setIsPlaying(false);
}
};
const handleDownload = () => { if (!morseCode) return;
const generator = new MorseAudioGenerator(wpm);
const blob = generator.generateWAV(morseCode);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'morse-code.wav';
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="flex gap-4">
<button onClick={handleSwapDirection}>
Swap Direction
</button>
<button onClick={handlePlayAudio} disabled={isPlaying}>
{isPlaying ? 'Playing...' : 'Play Audio'}
</button>
<button onClick={handleDownload}>
Download WAV
</button>
</div>
<div className="p-4 bg-gray-100 rounded-lg">
<code className="font-mono">{morseCode || 'Output will appear here...'}</code>
</div>
</div>
); } 挑战与解决方案 1.跨浏览器音频兼容性 问题: Chrome、Firefox 和 Safari 之间的音频时间有所不同。
解决方案:使用audioContext.currentTime而不是 进行精确调度setTimeout。应用平滑的启动/释放包络,以防止出现咔嗒声。
- Turbopack 热模块更换 问题: HMR 偶尔会因复杂的组件树而失败。
解决方案:简化组件结构,避免循环依赖。当 HMR 挂起时,重启开发服务器。
- WAV文件生成性能 问题:生成长摩尔斯电码信息导致用户界面冻结。
解决方案:考虑迁移到 Web Workers 进行处理(未来会增强)。目前可接受 1000 个字符以下的消息。
- 移动音频播放 问题: iOS 在播放音频之前需要用户交互。
解决方案:首次按下按钮时进行初始化AudioContext。显示清除播放按钮,而不是自动播放。
性能优化 // Memoize expensive calculations import { useMemo } from 'react';
const morseOutput = useMemo(() => { return textToMorse(inputText); }, [inputText]);
// Debounce real-time conversion import { useDebounce } from '@/hooks/useDebounce';
const debouncedInput = useDebounce(inputText, 300); 关键时间标准 摩尔斯电码计时遵循基于“PARIS”标准的严格比率:
元素 期间 比率 点 1个单位 1 短跑 3个单位 3 字符内差距 1个单位 1 字符间差距 3个单位 3 词间隙 7个单位 7 在 20 WPM(每分钟单词数)的情况下,单词“PARIS ”(包括尾随空格)应该正好需要 3 秒,因此 50 个点的持续时间 = 3000 毫秒,或每个点单位 60 毫秒。
后续步骤 我正在考虑的未来改进:
PWA 支持离线使用 用于音频处理的Web Workers 播放时显示可视化波形 带有随机课程的练习模式 开源核心编码逻辑 关键要点 可变长度编码很优雅,并且仍然适用 Web Audio API功能强大,但需要仔细的时间管理 Turbopack显著加快了构建速度,但仍然存在一些缺陷 教育工具不必枯燥乏味——现代网络技术让它们变得引人入胜 试用 查看摩尔斯电码翻译器的现场版本并告诉我您的想法!
如果您对完整源代码感兴趣或想贡献想法,请在下方留言。我正在考虑开源部分项目。作者www.youjiutian.com