import { randomUUID } from "node:crypto"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { mkdir, mkdtemp, rm, unlink, writeFile } from "node:fs/promises"; import { isMorseCandidate, textToMorseTokens, tokenizeMorse, type EncodeOptions, type DecodeOptions, type Separator } from "./translate.js"; export type MorseAudioInputType = "auto" | "text" | "morse"; export interface MorseAudioOptions extends EncodeOptions { inputType?: MorseAudioInputType; wpm?: number; frequency?: number; sampleRate?: number; volume?: number; letterSeparatorPattern?: Separator; wordSeparatorPattern?: Separator; } interface NormalizedAudioOptions { inputType: MorseAudioInputType; wpm: number; frequency: number; sampleRate: number; volume: number; letterSeparator: string; wordSeparator: string; letterSeparatorPattern?: Separator; wordSeparatorPattern?: Separator; } interface MorseSegment { type: "tone" | "gap"; units: number; } interface PlaybackHandle { process: ChildProcessWithoutNullStreams; filePath: string; directory: string; } const DOT_UNITS = 1; const DASH_UNITS = 3; const INTRA_CHAR_GAP_UNITS = 1; const LETTER_GAP_UNITS = 3; const WORD_GAP_UNITS = 7; const DEFAULT_WPM = 20; const DEFAULT_FREQUENCY = 600; const DEFAULT_SAMPLE_RATE = 44_100; const DEFAULT_VOLUME = 0.8; export class MorseAudio { private readonly options: NormalizedAudioOptions; private readonly tokens: string[][]; private cachedBuffer?: Buffer; private playback?: PlaybackHandle; constructor(private readonly input: string, options: MorseAudioOptions = {}) { this.options = normalizeAudioOptions(options); this.tokens = buildTokens(input, this.options); if (this.tokens.length === 0) { throw new Error("Input cannot be converted to Morse audio"); } } play(): Promise { if (this.playback) { this.stop(); } const buffer = this.ensureBuffer(); return this.spawnPlayback(buffer); } stop(): void { if (!this.playback) { return; } this.playback.process.kill(); void cleanupTempArtifacts(this.playback); this.playback = undefined; } async save(filePath: string): Promise { const directory = dirname(filePath); if (directory && directory !== ".") { await mkdir(directory, { recursive: true }); } await writeFile(filePath, this.ensureBuffer()); } toBuffer(): Buffer { return this.ensureBuffer(); } private ensureBuffer(): Buffer { if (!this.cachedBuffer) { const segments = tokensToSegments(this.tokens); this.cachedBuffer = renderSegmentsToWav(segments, this.options); } return this.cachedBuffer; } private async spawnPlayback(buffer: Buffer): Promise { const tempDir = await mkdtemp(join(tmpdir(), "morse-audio-")); const filePath = join(tempDir, `${randomUUID()}.wav`); await writeFile(filePath, buffer); const require = createRequire(import.meta.url); const playerFactory = require("play-sound"); const player = playerFactory(); await new Promise((resolve, reject) => { const process = player.play(filePath, (error: Error | null) => { void cleanupTempArtifacts({ process, filePath, directory: tempDir }); this.playback = undefined; if (error) { reject(error); return; } resolve(); }); this.playback = { process, filePath, directory: tempDir }; }); } } function normalizeAudioOptions(options: MorseAudioOptions): NormalizedAudioOptions { return { inputType: options.inputType ?? "auto", wpm: options.wpm ?? DEFAULT_WPM, frequency: options.frequency ?? DEFAULT_FREQUENCY, sampleRate: options.sampleRate ?? DEFAULT_SAMPLE_RATE, volume: clamp(options.volume ?? DEFAULT_VOLUME, 0, 1), letterSeparator: options.letterSeparator ?? " ", wordSeparator: options.wordSeparator ?? " / ", letterSeparatorPattern: options.letterSeparatorPattern, wordSeparatorPattern: options.wordSeparatorPattern }; } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } function buildTokens(input: string, options: NormalizedAudioOptions): string[][] { if (options.inputType === "morse") { return readTokensFromMorse(input, options); } if (options.inputType === "text") { return readTokensFromText(input, options); } return isMorseCandidate(input) ? readTokensFromMorse(input, options) : readTokensFromText(input, options); } function readTokensFromText(text: string, options: NormalizedAudioOptions): string[][] { const encodeOptions: EncodeOptions = { letterSeparator: options.letterSeparator, wordSeparator: options.wordSeparator }; return textToMorseTokens(text, encodeOptions); } function readTokensFromMorse(morse: string, options: NormalizedAudioOptions): string[][] { const decodeOptions: DecodeOptions = { letterSeparator: options.letterSeparatorPattern ?? options.letterSeparator, wordSeparator: options.wordSeparatorPattern ?? options.wordSeparator }; return tokenizeMorse(morse, decodeOptions); } function tokensToSegments(tokens: string[][]): MorseSegment[] { const segments: MorseSegment[] = []; tokens.forEach((letters, wordIndex) => { letters.forEach((code, letterIndex) => { Array.from(code).forEach((symbol, symbolIndex) => { segments.push({ type: "tone", units: symbol === "." ? DOT_UNITS : DASH_UNITS }); if (symbolIndex < code.length - 1) { segments.push({ type: "gap", units: INTRA_CHAR_GAP_UNITS }); } }); if (letterIndex < letters.length - 1) { segments.push({ type: "gap", units: LETTER_GAP_UNITS }); } }); if (wordIndex < tokens.length - 1) { segments.push({ type: "gap", units: WORD_GAP_UNITS }); } }); return segments; } function renderSegmentsToWav(segments: MorseSegment[], options: NormalizedAudioOptions): Buffer { const unitDuration = 1.2 / options.wpm; const totalSamples = segments.reduce((sum, segment) => sum + durationToSamples(segment.units * unitDuration, options.sampleRate), 0); const data = Buffer.alloc(totalSamples * 2); let time = 0; let offset = 0; const amplitude = options.volume; const sampleRate = options.sampleRate; const angularFrequency = 2 * Math.PI * options.frequency; segments.forEach((segment) => { const samples = durationToSamples(segment.units * unitDuration, sampleRate); for (let i = 0; i < samples; i += 1) { const sampleValue = segment.type === "tone" ? Math.sin(angularFrequency * time) * amplitude : 0; const clamped = Math.max(-1, Math.min(1, sampleValue)); const intSample = Math.trunc(clamped * 0x7fff); data.writeInt16LE(intSample, offset); offset += 2; time += 1 / sampleRate; } }); return createWavFile(data, sampleRate); } function durationToSamples(durationSeconds: number, sampleRate: number): number { const samples = Math.round(durationSeconds * sampleRate); return Math.max(samples, 1); } function createWavFile(data: Buffer, sampleRate: number, channels = 1, bitsPerSample = 16): Buffer { const header = Buffer.alloc(44); const blockAlign = (channels * bitsPerSample) / 8; const byteRate = sampleRate * blockAlign; header.write("RIFF", 0); header.writeUInt32LE(36 + data.length, 4); header.write("WAVE", 8); header.write("fmt ", 12); header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(channels, 22); header.writeUInt32LE(sampleRate, 24); header.writeUInt32LE(byteRate, 28); header.writeUInt16LE(blockAlign, 32); header.writeUInt16LE(bitsPerSample, 34); header.write("data", 36); header.writeUInt32LE(data.length, 40); return Buffer.concat([header, data]); } export function audio(input: string, options?: MorseAudioOptions): MorseAudio { return new MorseAudio(input, options); } function cleanupTempArtifacts(handle: PlaybackHandle): Promise { return rm(handle.directory, { recursive: true, force: true }).catch(() => undefined); }