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
-
-
- 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
+
+[](https://github.com/rbibby53/HAMRadioFoxHuntControllerJava/actions/workflows/build.yml)
+[](LICENSE)
+[](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;
+ };
+ }
+}