diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..035651e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..8a478a4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,41 @@ +name: Bug report +description: Report something that isn't working +labels: ["bug"] +body: + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you expect, and what happened instead? + validations: + required: true + - type: input + id: command + attributes: + label: Command you ran + placeholder: java -jar fox.jar --callsign W1ABC --wpm 20 + validations: + required: true + - type: input + id: java-version + attributes: + label: Java version + description: Output of `java -version` + placeholder: openjdk 26 + validations: + required: true + - type: input + id: os + attributes: + label: Operating system + placeholder: macOS 15 / Ubuntu 24.04 / Windows 11 + validations: + required: true + - type: textarea + id: logs + attributes: + label: Log output + description: Paste any relevant console output. + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..8a4c260 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,24 @@ +name: Feature request +description: Suggest an idea or improvement +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: What problem would this solve? + description: Describe the use case or the limitation you're hitting. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: How might it work? A sketch of the CLI flags or behavior helps. + validations: + required: false + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..cba13e5 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +## What does this PR do? + + + +## Related issue + + + +## How was it tested? + + + +## Checklist + +- [ ] CI passes (compiles, packages, smoke-tests the jar) +- [ ] No new external dependencies +- [ ] README / `--help` updated if behavior or options changed +- [ ] CHANGELOG.md updated under "Unreleased" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..004e490 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,35 @@ +name: build + +on: + push: + branches: ["**"] + tags: ["v*"] + pull_request: + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "26" + - name: Compile + run: javac -d out/classes src/fox/*.java + - name: Package jar + run: jar --create --file fox.jar --main-class fox.Fox -C out/classes . + - name: Smoke test + run: java -jar fox.jar --help + - uses: actions/upload-artifact@v4 + with: + name: fox-jar + path: fox.jar + - name: Attach jar to release + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "$GITHUB_REF_NAME" fox.jar --generate-notes diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e28387a --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Build output +out/ +*.class + +# macOS +.DS_Store + +# IntelliJ +.idea/workspace.xml +.idea/shelf/ diff --git a/.idea/misc.xml b/.idea/misc.xml index 2a824a2..6ea4ea0 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml deleted file mode 100644 index de6696b..0000000 --- a/.idea/workspace.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1597944401940 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8c70d04 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to this project are documented here. The format is based +on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [2.0.0] - 2026-06-11 + +### Added +- Synthesize the morse ID from `--callsign` in memory (configurable `--wpm` + and `--tone`); no audio recordings required. +- `--morse-file` to play a recorded WAV as the ID instead. +- `--gap` and `--random-gap` for fixed or randomized quiet periods. +- `--start`/`--stop` times for timed hunts. +- `--cycles` to limit the number of transmit cycles (`0` = run until stopped). +- Timestamped logging of every transmission. +- GitHub Actions workflow that builds, smoke-tests, and releases `fox.jar`. +- Contributor scaffolding: CONTRIBUTING, code of conduct, issue/PR templates. + +### Changed +- Requires Java 26. +- Default behavior is now run-until-stopped instead of a fixed 4000 cycles. + +### Fixed +- Filename case mismatch that broke playback on case-sensitive filesystems. +- Native audio line leak from opening a new clip per playback. +- Inaccurate playback timing; playback now waits for the real end-of-clip event. +- Errors are reported instead of silently swallowed. + +### Removed +- Bundled `morse.wav` and `shh.wav`; the ID is synthesized and gaps are timed. + +[Unreleased]: https://github.com/rbibby53/HAMRadioFoxHuntControllerJava/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/rbibby53/HAMRadioFoxHuntControllerJava/releases/tag/v2.0.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..1e38d51 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,40 @@ +# Code of Conduct + +## Our pledge + +We as members, contributors, and maintainers pledge to make participation in +this project a harassment-free experience for everyone, regardless of age, +body size, visible or invisible disability, ethnicity, sex characteristics, +gender identity and expression, level of experience, education, socio-economic +status, nationality, personal appearance, race, religion, or sexual identity +and orientation. + +## Our standards + +Examples of behavior that contributes to a positive environment: + +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes +- Focusing on what is best for the community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without their explicit permission + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainer through a GitHub issue or direct contact. +All complaints will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3ed9110 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,45 @@ +# Contributing + +Thanks for your interest in improving the Fox Hunt Controller! Bug reports, +feature ideas, and pull requests are all welcome. + +## Reporting bugs and requesting features + +Open an issue using the matching template. For bugs, include your OS, Java +version (`java -version`), the exact command you ran, and what happened. + +## Development setup + +You need Java 26 or newer — nothing else. + +```sh +git clone https://github.com/rbibby53/HAMRadioFoxHuntControllerJava.git +cd HAMRadioFoxHuntControllerJava +javac -d out/classes src/fox/*.java +jar --create --file fox.jar --main-class fox.Fox -C out/classes . +``` + +## Testing your changes + +There's no test suite (yet — contributions welcome!), so test by running: + +```sh +java -jar fox.jar --help +java -jar fox.jar --callsign N0CALL --wpm 25 --repeats 1 --gap 1 --cycles 2 +``` + +You should hear the callsign in morse twice, with a quiet gap between, and +see a timestamped log line for each transmission. Please also exercise any +option your change touches. + +## Pull requests + +- Branch from `master` and keep PRs focused on one change. +- CI must pass — it compiles, packages, and smoke-tests the jar. +- Match the existing code style: plain Java, 4-space indent, no external + dependencies. Part of this project's appeal is that it's a single small + jar with zero dependencies; please keep it that way. + +## Questions + +Open an issue — happy to discuss ideas before you write code. diff --git a/README.md b/README.md new file mode 100644 index 0000000..027adb7 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# HAM Radio Fox Hunt Controller + +[![build](https://github.com/rbibby53/HAMRadioFoxHuntControllerJava/actions/workflows/build.yml/badge.svg)](https://github.com/rbibby53/HAMRadioFoxHuntControllerJava/actions/workflows/build.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Java 26](https://img.shields.io/badge/Java-26-orange.svg)](https://adoptium.net/) + +A small Java program that drives a hidden transmitter ("fox") for amateur +radio fox hunts. It transmits a morse-code identifier on a loop with quiet +gaps between transmissions — feed the computer's audio output into the +transmitter (e.g. through its VOX circuit or a simple audio interface) and +hunters can direction-find the signal. + +The morse ID is **synthesized from your callsign** — no recordings needed. + +## Quick start + +```sh +java -jar fox.jar --callsign W1ABC +``` + +That transmits `W1ABC` in morse 5 times, goes quiet for 30 seconds, and +repeats until you stop it (Ctrl-C). Every transmission is logged with a +timestamp. + +## Options + +| Option | Meaning | Default | +|---------------------|----------------------------------------------------|---------| +| `--callsign ` | Callsign to send as the morse ID | — | +| `--morse-file ` | Play a recorded WAV as the ID instead | — | +| `--wpm ` | Morse speed in words per minute | 15 | +| `--tone ` | Morse tone frequency | 700 | +| `--repeats ` | Times the ID plays per cycle | 5 | +| `--gap ` | Quiet time between cycles | 30 | +| `--random-gap ` | Add 0–n random extra seconds to each gap | 0 | +| `--cycles ` | Cycles before exiting; `0` = run until stopped | 0 | +| `--start ` | Wait until this time of day before transmitting | — | +| `--stop ` | Stop transmitting at this time of day | — | + +Examples: + +```sh +# Faster morse, lower tone, one-minute gaps +java -jar fox.jar --callsign W1ABC --wpm 20 --tone 600 --gap 60 + +# Harder hunt: gaps vary randomly between 30 and 90 seconds +java -jar fox.jar --callsign W1ABC --random-gap 60 + +# Run a timed hunt from 09:00 to 12:00 +java -jar fox.jar --callsign W1ABC --start 09:00 --stop 12:00 + +# Use a recorded ID instead of the synthesizer +java -jar fox.jar --morse-file myid.wav +``` + +`--start`/`--stop` are same-day times; the fox waits for the start time, +then exits at the stop time. + +## Build + +Requires Java 26 or newer (e.g. `brew install openjdk` on macOS). + +```sh +javac -d out/classes src/fox/*.java +jar --create --file fox.jar --main-class fox.Fox -C out/classes . +``` + +Or grab `fox.jar` from the [releases page](../../releases) — CI builds it on +every push and attaches it to releases on `v*` tags. + +## Operating note + +This software keys a transmitter. Transmitting on amateur radio bands +requires an appropriate license, and you must identify with **your own** +callsign and follow your local regulations (in the US, FCC Part 97) on +power, frequency, and station identification. Use a callsign you are +licensed to operate under — don't transmit `N0CALL` or someone else's call. +The authors provide this tool as-is and accept no responsibility for how it +is used. + +## Contributing + +Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) and the +[Code of Conduct](CODE_OF_CONDUCT.md). Changes are tracked in +[CHANGELOG.md](CHANGELOG.md). + +## License + +[MIT](LICENSE) © rbibby53 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..38c4963 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +This is a small, dependency-free utility, so the attack surface is minimal. +Still, if you discover a security issue, please report it responsibly. + +## Reporting a vulnerability + +Please **do not** open a public issue for security problems. Instead, use +GitHub's [private vulnerability reporting][report] for this repository +("Security" tab → "Report a vulnerability"). You'll get a response as soon as +the maintainer is able. + +[report]: https://github.com/rbibby53/HAMRadioFoxHuntControllerJava/security/advisories/new + +## Supported versions + +The latest release on the `master` branch is supported. Older versions are +not maintained. diff --git a/morse.wav b/morse.wav deleted file mode 100644 index 95f73b2..0000000 Binary files a/morse.wav and /dev/null differ diff --git a/out/artifacts/Fox_jar/Fox.jar b/out/artifacts/Fox_jar/Fox.jar deleted file mode 100644 index 9ce3989..0000000 Binary files a/out/artifacts/Fox_jar/Fox.jar and /dev/null differ diff --git a/out/production/Fox/Fox.class b/out/production/Fox/Fox.class deleted file mode 100644 index 12a0f3c..0000000 Binary files a/out/production/Fox/Fox.class and /dev/null differ diff --git a/out/production/Fox/META-INF/MANIFEST.MF b/out/production/Fox/META-INF/MANIFEST.MF deleted file mode 100644 index 30bd595..0000000 --- a/out/production/Fox/META-INF/MANIFEST.MF +++ /dev/null @@ -1,3 +0,0 @@ -Manifest-Version: 1.0 -Main-Class: Fox - diff --git a/shh.wav b/shh.wav deleted file mode 100644 index 41a5c7f..0000000 Binary files a/shh.wav and /dev/null differ diff --git a/src/Fox.java b/src/Fox.java deleted file mode 100644 index f07a990..0000000 --- a/src/Fox.java +++ /dev/null @@ -1,26 +0,0 @@ -import java.io.File; -import javax.sound.sampled.AudioSystem; -import javax.sound.sampled.Clip; - -public class Fox{ - public static void main(String[] args){ - File file1 = new File("morse.WAV"); - File file2 = new File("shh.WAV"); - for (int j = 0; j < 4000; j++){ - for (int i = 0; i < 5; i++) { - playSound(file1); - } - playSound(file2); - } - } - static void playSound(File sound){ - try{ - Clip clip = AudioSystem.getClip(); - clip.open(AudioSystem.getAudioInputStream(sound)); - clip.start(); - Thread.sleep(clip.getMicrosecondLength()/860); - }catch(Exception e){ - Thread.currentThread().interrupt(); - } - } -} \ No newline at end of file diff --git a/src/META-INF/MANIFEST.MF b/src/META-INF/MANIFEST.MF index 30bd595..d044701 100644 --- a/src/META-INF/MANIFEST.MF +++ b/src/META-INF/MANIFEST.MF @@ -1,3 +1,3 @@ Manifest-Version: 1.0 -Main-Class: Fox +Main-Class: fox.Fox diff --git a/src/fox/Fox.java b/src/fox/Fox.java new file mode 100644 index 0000000..1ebd0cf --- /dev/null +++ b/src/fox/Fox.java @@ -0,0 +1,209 @@ +package fox; + +import java.io.File; +import java.io.IOException; +import java.time.Duration; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ThreadLocalRandom; +import javax.sound.sampled.AudioSystem; +import javax.sound.sampled.Clip; +import javax.sound.sampled.LineEvent; +import javax.sound.sampled.LineListener; +import javax.sound.sampled.LineUnavailableException; +import javax.sound.sampled.UnsupportedAudioFileException; + +/** + * Fox hunt transmitter controller for amateur radio hidden-transmitter hunts. + * + * Transmits a morse-code identifier several times, goes quiet for a gap, and + * repeats. Feed the audio output into the transmitter (e.g. via VOX). + */ +public class Fox { + private static final String USAGE = """ + Usage: java -jar fox.jar --callsign [options] + java -jar fox.jar --morse-file [options] + + --callsign callsign to send as the morse ID + --morse-file play a recorded WAV as the ID instead + --wpm morse speed in words per minute (default 15) + --tone morse tone frequency (default 700) + --repeats times the ID plays per cycle (default 5) + --gap quiet time between cycles (default 30) + --random-gap add 0..n random extra seconds to each gap (default 0) + --cycles cycles before exiting; 0 = until stopped (default 0) + --start wait until this time of day before transmitting + --stop stop transmitting at this time of day + --help show this help\ + """; + + private static final DateTimeFormatter LOG_TIME = DateTimeFormatter.ofPattern("HH:mm:ss"); + + record Config(String callsign, File morseFile, int wpm, int toneHz, int repeats, + int gapSeconds, int randomGapSeconds, long cycles, + LocalTime start, LocalTime stop) {} + + public static void main(String[] args) { + Config cfg = parseArgs(args); + String id = cfg.morseFile() != null + ? cfg.morseFile().getName() + : cfg.callsign() + " at " + cfg.wpm() + " wpm"; + try (Clip morse = cfg.morseFile() != null + ? loadClip(cfg.morseFile()) + : Morse.clipFor(cfg.callsign(), cfg.wpm(), cfg.toneHz())) { + if (cfg.start() != null) { + waitUntilStart(cfg.start()); + } + for (long cycle = 1; cfg.cycles() == 0 || cycle <= cfg.cycles(); cycle++) { + if (cfg.stop() != null && !LocalTime.now().isBefore(cfg.stop())) { + log("Stop time " + cfg.stop() + " reached."); + break; + } + log("TX " + id + " x" + cfg.repeats() + + (cfg.cycles() == 0 ? " (cycle " + cycle + ")" + : " (cycle " + cycle + "/" + cfg.cycles() + ")")); + for (int i = 0; i < cfg.repeats(); i++) { + play(morse); + } + quietGap(cfg); + } + log("Done."); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (IllegalArgumentException e) { + fail(e.getMessage()); + } catch (IOException | LineUnavailableException | UnsupportedAudioFileException e) { + System.err.println("Audio error: " + e); + System.exit(1); + } + } + + private static void quietGap(Config cfg) throws InterruptedException { + int gap = cfg.gapSeconds(); + if (cfg.randomGapSeconds() > 0) { + gap += ThreadLocalRandom.current().nextInt(cfg.randomGapSeconds() + 1); + } + long sleepMs = gap * 1000L; + if (cfg.stop() != null) { + long untilStop = Duration.between(LocalTime.now(), cfg.stop()).toMillis(); + sleepMs = Math.max(0, Math.min(sleepMs, untilStop)); + } + log("Quiet for " + gap + "s"); + Thread.sleep(sleepMs); + } + + private static void waitUntilStart(LocalTime start) throws InterruptedException { + var now = LocalTime.now(); + if (now.isBefore(start)) { + log("Waiting until " + start + " to begin transmitting."); + Thread.sleep(Duration.between(now, start).toMillis()); + } + } + + private static Config parseArgs(String[] args) { + String callsign = null; + File morseFile = null; + int wpm = 15, toneHz = 700, repeats = 5, gapSeconds = 30, randomGapSeconds = 0; + long cycles = 0; + LocalTime start = null, stop = null; + + for (int i = 0; i < args.length; i++) { + String flag = args[i]; + if (flag.equals("--help") || flag.equals("-h")) { + System.out.println(USAGE); + System.exit(0); + } + if (i + 1 >= args.length) { + fail("Missing value for " + flag); + } + String value = args[++i]; + switch (flag) { + case "--callsign" -> callsign = value; + case "--morse-file" -> morseFile = new File(value); + case "--wpm" -> wpm = parseInt(flag, value, 5, 60); + case "--tone" -> toneHz = parseInt(flag, value, 100, 3000); + case "--repeats" -> repeats = parseInt(flag, value, 1, 100); + case "--gap" -> gapSeconds = parseInt(flag, value, 0, 24 * 3600); + case "--random-gap" -> randomGapSeconds = parseInt(flag, value, 0, 24 * 3600); + case "--cycles" -> cycles = parseInt(flag, value, 0, Integer.MAX_VALUE); + case "--start" -> start = parseTime(flag, value); + case "--stop" -> stop = parseTime(flag, value); + default -> fail("Unknown option " + flag); + } + } + if (callsign == null && morseFile == null) { + fail("Either --callsign or --morse-file is required."); + } + if (callsign != null && morseFile != null) { + fail("Use --callsign or --morse-file, not both."); + } + return new Config(callsign, morseFile, wpm, toneHz, repeats, + gapSeconds, randomGapSeconds, cycles, start, stop); + } + + private static int parseInt(String flag, String value, int min, int max) { + try { + int n = Integer.parseInt(value); + if (n < min || n > max) { + fail(flag + " must be between " + min + " and " + max); + } + return n; + } catch (NumberFormatException e) { + fail(flag + " expects a number, got '" + value + "'"); + return 0; // unreachable + } + } + + private static LocalTime parseTime(String flag, String value) { + try { + return LocalTime.parse(value); + } catch (DateTimeParseException e) { + fail(flag + " expects a time like 09:30, got '" + value + "'"); + return null; // unreachable + } + } + + private static void fail(String message) { + System.err.println(message); + System.err.println(); + System.err.println(USAGE); + System.exit(2); + } + + private static Clip loadClip(File file) + throws IOException, LineUnavailableException, UnsupportedAudioFileException { + if (!file.isFile()) { + throw new IOException(file + " not found"); + } + try (var in = AudioSystem.getAudioInputStream(file)) { + Clip clip = AudioSystem.getClip(); + clip.open(in); + return clip; + } + } + + /** Plays the clip from the beginning and blocks until playback finishes. */ + private static void play(Clip clip) throws InterruptedException { + var done = new CountDownLatch(1); + LineListener listener = event -> { + if (event.getType() == LineEvent.Type.STOP) { + done.countDown(); + } + }; + clip.addLineListener(listener); + try { + clip.setFramePosition(0); + clip.start(); + done.await(); + } finally { + clip.removeLineListener(listener); + clip.stop(); + } + } + + private static void log(String message) { + System.out.println("[" + LocalTime.now().format(LOG_TIME) + "] " + message); + } +} diff --git a/src/fox/Morse.java b/src/fox/Morse.java new file mode 100644 index 0000000..3cfd7eb --- /dev/null +++ b/src/fox/Morse.java @@ -0,0 +1,100 @@ +package fox; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.AudioInputStream; +import javax.sound.sampled.AudioSystem; +import javax.sound.sampled.Clip; +import javax.sound.sampled.LineUnavailableException; + +/** Renders text as morse-code audio, entirely in memory. */ +final class Morse { + private static final float SAMPLE_RATE = 16000f; + private static final double AMPLITUDE = 0.8; + private static final double RAMP_SECONDS = 0.005; // fade in/out to avoid clicks + + private Morse() {} + + /** + * Builds a playable clip of {@code text} in morse code using standard + * (PARIS) timing: a dit lasts 1.2/wpm seconds, a dah three dits; gaps are + * one dit within a letter, three between letters, seven between words. + * A word gap is appended at the end so back-to-back plays stay readable. + */ + static Clip clipFor(String text, int wpm, int toneHz) + throws IOException, LineUnavailableException { + byte[] pcm = render(text.toUpperCase(), wpm, toneHz); + var format = new AudioFormat(SAMPLE_RATE, 16, 1, true, false); + try (var in = new AudioInputStream(new ByteArrayInputStream(pcm), format, pcm.length / 2)) { + Clip clip = AudioSystem.getClip(); + clip.open(in); + return clip; + } + } + + private static byte[] render(String text, int wpm, int toneHz) { + int dit = (int) (SAMPLE_RATE * 1.2 / wpm); + var out = new ByteArrayOutputStream(); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == ' ') { + silence(out, 4 * dit); // 3 dits from the letter gap already written + continue; + } + String symbols = code(c); + if (symbols == null) { + throw new IllegalArgumentException("No morse code for character '" + c + "'"); + } + for (char symbol : symbols.toCharArray()) { + tone(out, symbol == '.' ? dit : 3 * dit, toneHz); + silence(out, dit); + } + silence(out, 2 * dit); // total of 3 dits between letters + } + silence(out, 4 * dit); // total of 7 dits before any repeat + return out.toByteArray(); + } + + private static void tone(ByteArrayOutputStream out, int samples, int toneHz) { + int ramp = Math.min((int) (SAMPLE_RATE * RAMP_SECONDS), samples / 2); + for (int i = 0; i < samples; i++) { + double envelope = 1.0; + if (i < ramp) { + envelope = i / (double) ramp; + } else if (i >= samples - ramp) { + envelope = (samples - 1 - i) / (double) ramp; + } + double value = AMPLITUDE * envelope * Math.sin(2 * Math.PI * toneHz * i / SAMPLE_RATE); + int sample = (int) (value * Short.MAX_VALUE); + out.write(sample & 0xFF); + out.write((sample >> 8) & 0xFF); + } + } + + private static void silence(ByteArrayOutputStream out, int samples) { + for (int i = 0; i < 2 * samples; i++) { + out.write(0); + } + } + + private static String code(char c) { + return switch (c) { + case 'A' -> ".-"; case 'B' -> "-..."; case 'C' -> "-.-."; + case 'D' -> "-.."; case 'E' -> "."; case 'F' -> "..-."; + case 'G' -> "--."; case 'H' -> "...."; case 'I' -> ".."; + case 'J' -> ".---"; case 'K' -> "-.-"; case 'L' -> ".-.."; + case 'M' -> "--"; case 'N' -> "-."; case 'O' -> "---"; + case 'P' -> ".--."; case 'Q' -> "--.-"; case 'R' -> ".-."; + case 'S' -> "..."; case 'T' -> "-"; case 'U' -> "..-"; + case 'V' -> "...-"; case 'W' -> ".--"; case 'X' -> "-..-"; + case 'Y' -> "-.--"; case 'Z' -> "--.."; + case '0' -> "-----"; case '1' -> ".----"; case '2' -> "..---"; + case '3' -> "...--"; case '4' -> "....-"; case '5' -> "....."; + case '6' -> "-...."; case '7' -> "--..."; case '8' -> "---.."; + case '9' -> "----."; case '/' -> "-..-."; + default -> null; + }; + } +}