Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions ft8af/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@
GPS breadcrumbs coming while the phone sits in a cradle with the screen off. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

<!-- Voice assistant: package-visibility (API 30+) for the on-device speech
recognizer and the system TTS engine. Without these, SpeechRecognizer
.isRecognitionAvailable() and TextToSpeech init can fail on Android 11+. -->
<queries>
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries>

<application
android:name="radio.ks3ckc.ft8af.FT8AFApplication"
android:allowBackup="false"
Expand Down
17 changes: 17 additions & 0 deletions ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,23 @@ public static String excludedBandsToCsv() {
public static boolean alertOnCqReply = false;
public static boolean alertOnQsoComplete = false;

// Voice assistant (Settings → Voice Assistant). All opt-in, default off.
// - voiceAnnounce*: spoken TTS announcements (station calling me, QSO logged,
// new-DXCC CQ, new-prefix CQ) — see voice/VoiceAnnouncer + DxAlertNotifier.
// - voiceCommandsEnabled: shows the push-to-talk mic button on the main screen
// for one-shot voice commands (answer / call CQ / stop / skip / log it).
public static boolean voiceAnnounceCalling = false;
public static boolean voiceAnnounceQsoComplete = false;
public static boolean voiceAnnounceNewDxcc = false;
public static boolean voiceAnnounceNewPrefix = false;
public static boolean voiceCommandsEnabled = false;
// Observable mirror of voiceCommandsEnabled: the mic button lives on the main
// screen, which is already composed when the settings toggle flips — a plain
// static read there never recomposes, so the button only appeared after an
// app restart. Settings writes and config hydration must update both.
public static final MutableLiveData<Boolean> mutableVoiceCommandsEnabled =
new MutableLiveData<>(false);

// Geographic continent-directed CQ tokens — matched against myContinent.
private static final java.util.Set<String> CONTINENT_CODES =
new java.util.HashSet<>(java.util.Arrays.asList("NA", "SA", "EU", "AF", "AS", "OC", "AN"));
Expand Down
66 changes: 66 additions & 0 deletions ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,25 @@ public void doOnAfterQueryCallsignLocation(CallsignInfo callsignInfo) {
meterProtectionController.setTransmitSignal(ft8TransmitSignal);
ft8TransmitSignal.setMeterProtectionController(meterProtectionController);

// Voice assistant: TTS must never leak into a transmission (it would be
// mixed into the rig audio and go out over the air — see the TX audio
// hazards in CLAUDE.md). Two layers: the announcer refuses to start an
// utterance while isTransmitting(), and this observer hard-stops any
// in-flight speech the instant TX begins. observeForever is safe here:
// the ViewModel constructor runs on the main thread (setValue above),
// and both objects live for the whole process.
{
final com.k1af.ft8af.voice.VoiceAnnouncer announcer =
dxAlertNotifier.getVoiceAnnouncer();
announcer.setTransmitGate(
() -> ft8TransmitSignal != null && ft8TransmitSignal.isTransmitting());
ft8TransmitSignal.mutableIsTransmitting.observeForever(transmitting -> {
if (Boolean.TRUE.equals(transmitting)) {
announcer.stopNow();
}
});
}

//bring up the WSJT-X UDP interface (status provider + inbound request handlers)
setupWsjtxUdp();

Expand Down Expand Up @@ -1325,6 +1344,53 @@ public void callStation(Ft8Message message) {
ft8TransmitSignal.transmitNow();
}

// --- Voice assistant --------------------------------------------------------

/**
* Whether FT8 RX currently holds an Android audio-capture session (system
* mic or Android-routed USB input). The voice-command push-to-talk button
* is disabled while true: a SpeechRecognizer would fight our capture under
* Android's concurrent-capture rules. False for direct-libusb USB audio
* and LAN audio sources, where the capture stack is free.
*/
public boolean isPhoneMicInUse() {
return hamRecorder != null && hamRecorder.isPhoneMicInUse();
}

/** The voice announcer (TTS), for the command button's spoken echo. */
public com.k1af.ft8af.voice.VoiceAnnouncer getVoiceAnnouncer() {
return dxAlertNotifier.getVoiceAnnouncer();
}

/**
* "Answer" voice command: call the best current candidate — the newest
* decode addressed to me, else the caller-queue head's newest decode
* (selection logic in {@link com.k1af.ft8af.voice.VoiceAnswerSelector}).
*
* @return the callsign being answered, or null when there was no candidate
* (the spoken/toast feedback branches on this)
*/
public String voiceAnswerBestCaller() {
if (ft8TransmitSignal == null) return null;
ArrayList<Ft8Message> snapshot;
synchronized (ft8Messages) {
snapshot = new ArrayList<>(ft8Messages);
}
ArrayList<com.k1af.ft8af.ft8transmit.QueuedCaller> queue =
ft8TransmitSignal.mutableCallerQueue.getValue();
String queueHead = (queue != null && !queue.isEmpty()) ? queue.get(0).callsign : null;

Ft8Message target = com.k1af.ft8af.voice.VoiceAnswerSelector.pick(
snapshot,
m -> GeneralVariables.checkIsMyCallsign(m.getCallsignTo()),
Ft8Message::getCallsignFrom,
queueHead);
if (target == null) return null;
// callStation guards against TX-in-progress, empty sender, and own call.
callStation(target);
return target.getCallsignFrom();
}

// --- WSJT-X UDP interface -------------------------------------------------

/**
Expand Down
100 changes: 99 additions & 1 deletion ft8af/app/src/main/java/com/k1af/ft8af/alert/DxAlertNotifier.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
import com.k1af.ft8af.Ft8Message;
import com.k1af.ft8af.GeneralVariables;
import com.k1af.ft8af.R;
import com.k1af.ft8af.callsign.WpxPrefix;
import com.k1af.ft8af.log.QSLRecord;
import com.k1af.ft8af.voice.VoiceAnnouncementDecisions;
import com.k1af.ft8af.voice.VoiceAnnouncer;
import com.k1af.ft8af.voice.VoicePhrases;

import java.util.Collections;
import java.util.List;
Expand Down Expand Up @@ -54,12 +58,22 @@ public class DxAlertNotifier {
private final Context appContext;
// Already-alerted keys this session (namespaced: "DXCC:"/"STATE:"/"CQREPLY:"/"QSO:").
private final Set<String> alerted = Collections.newSetFromMap(new ConcurrentHashMap<>());
// Voice assistant: spoken announcements ride the same decode/QSO hooks as the
// notification alerts, so the announcer is co-located here. TTS init is lazy
// inside the announcer — nothing spins up unless a voice toggle is enabled.
private final VoiceAnnouncer voiceAnnouncer;

public DxAlertNotifier(Context context) {
this.appContext = context != null ? context.getApplicationContext() : null;
this.voiceAnnouncer = new VoiceAnnouncer(this.appContext);
createChannels();
}

/** The announcer, for TX-mute wiring (MainViewModel) and command echo (UI). */
public VoiceAnnouncer getVoiceAnnouncer() {
return voiceAnnouncer;
}

private void createChannels() {
if (appContext == null) return;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
Expand Down Expand Up @@ -96,8 +110,13 @@ private void createHighChannel(NotificationManager nm, String id, String name, S
*/
public void processDecodes(List<Ft8Message> messages) {
if (appContext == null || messages == null) return;
boolean anyVoice = VoiceAnnouncementDecisions.anyDecodeAnnounceEnabled(
GeneralVariables.voiceAnnounceCalling,
GeneralVariables.voiceAnnounceNewDxcc,
GeneralVariables.voiceAnnounceNewPrefix);
if (!GeneralVariables.alertNewDxcc && !GeneralVariables.alertNewState
&& !GeneralVariables.alertOnCqReply && !GeneralVariables.hasWatchCallsigns()) {
&& !GeneralVariables.alertOnCqReply && !GeneralVariables.hasWatchCallsigns()
&& !anyVoice) {
return;
}

Expand Down Expand Up @@ -130,6 +149,11 @@ public void processDecodes(List<Ft8Message> messages) {
cqReplyBody(msg), msg.getCallsignFrom(), msg.band);
}

// Voice assistant: spoken announcement, independent toggles from the
// notification alerts. Runs before the CQ-only gate because the
// calling-me announcement (like the CQ-reply alert) isn't a CQ.
maybeAnnounceVoice(msg, addressedToMe);

// Needed-DX alerts apply to CQ broadcasts only.
if (!msg.checkIsCQ()) continue;

Expand All @@ -146,9 +170,70 @@ public void processDecodes(List<Ft8Message> messages) {
}
}

/**
* Voice-assistant arm of {@link #processDecodes}: decide + dedup + speak.
* Blocked messages never reach here (the caller {@code continue}s on them),
* and the dedup claim is gated on canSpeakNow() so a station first decoded
* during a transmission isn't silenced for the whole session.
*/
private void maybeAnnounceVoice(Ft8Message msg, boolean addressedToMe) {
boolean isCq = msg.checkIsCQ();

// New-prefix predicate, only computed when it could matter (mirrors the
// Kotlin isNewPrefixStation logic: WpxPrefix + checkQSLPrefix).
String prefix = null;
boolean fromNewPrefix = false;
if (GeneralVariables.voiceAnnounceNewPrefix && isCq) {
prefix = WpxPrefix.of(msg.getCallsignFrom());
fromNewPrefix = prefix != null && !GeneralVariables.checkQSLPrefix(prefix);
}

VoiceAnnouncementDecisions.Kind kind = VoiceAnnouncementDecisions.decide(
GeneralVariables.voiceAnnounceCalling,
GeneralVariables.voiceAnnounceNewDxcc,
GeneralVariables.voiceAnnounceNewPrefix,
addressedToMe, isCq, msg.fromDxcc, fromNewPrefix,
false /* blocked messages already filtered by the caller */);
if (kind == null) return;

String key;
String phrase;
switch (kind) {
case CALLING_ME:
key = VoiceAnnouncementDecisions.callingMeKey(msg.getCallsignFrom());
phrase = VoicePhrases.callingYou(msg.getCallsignFrom(), msg.snr);
break;
case NEW_DXCC:
// Same fallback as the notification arm: country name when
// resolved, else the (spelled) callsign.
boolean noCountry = msg.fromWhere == null || msg.fromWhere.isEmpty();
String country = noCountry ? msg.getCallsignFrom() : msg.fromWhere;
key = VoiceAnnouncementDecisions.newDxccKey(country);
phrase = VoicePhrases.newCountry(
noCountry ? VoicePhrases.spellCallsign(country) : country);
break;
default: // NEW_PREFIX
key = VoiceAnnouncementDecisions.newPrefixKey(prefix);
phrase = VoicePhrases.newPrefix(prefix);
break;
}

if (!VoiceAnnouncementDecisions.claim(
voiceAnnouncer.spokenKeys(), key, voiceAnnouncer.canSpeakNow())) {
return;
}
GeneralVariables.fileLog("VOICE announce key=[" + key + "] " + phrase);
voiceAnnouncer.speak(phrase, key);
}

/** Fire a notification when a QSO has just been logged, if the user enabled it. */
public void notifyQsoComplete(QSLRecord qslRecord) {
if (appContext == null || qslRecord == null) return;

// Voice announcement first — its toggle is independent of the
// notification toggle below.
announceQsoCompleteVoice(qslRecord);

if (!GeneralVariables.alertOnQsoComplete) return;

String call = qslRecord.getToCallsign();
Expand All @@ -168,6 +253,19 @@ public void notifyQsoComplete(QSLRecord qslRecord) {
body.toString(), call, qslRecord.getBandFreq());
}

/** "QSO with K 1 A B C logged" — once per logged contact, opt-in. */
private void announceQsoCompleteVoice(QSLRecord qslRecord) {
if (!GeneralVariables.voiceAnnounceQsoComplete) return;
String call = qslRecord.getToCallsign();
String key = VoiceAnnouncementDecisions.qsoCompleteKey(call, qslRecord.getEndTime());
if (!VoiceAnnouncementDecisions.claim(
voiceAnnouncer.spokenKeys(), key, voiceAnnouncer.canSpeakNow())) {
return;
}
GeneralVariables.fileLog("VOICE announce key=[" + key + "]");
voiceAnnouncer.speak(VoicePhrases.qsoLogged(call), key);
}

static String defaultBody(Ft8Message msg) {
StringBuilder body = new StringBuilder(msg.getCallsignFrom());
if (msg.maidenGrid != null && !msg.maidenGrid.isEmpty()) {
Expand Down
19 changes: 19 additions & 0 deletions ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java
Original file line number Diff line number Diff line change
Expand Up @@ -2996,6 +2996,25 @@ protected Void doInBackground(Void... voids) {
if (name.equalsIgnoreCase("alertOnQsoComplete")) {//Alert when a QSO completes
GeneralVariables.alertOnQsoComplete = result.equals("1");
}
if (name.equalsIgnoreCase("voiceAnnounceCalling")) {//Voice: announce station calling me
GeneralVariables.voiceAnnounceCalling = result.equals("1");
}
if (name.equalsIgnoreCase("voiceAnnounceQsoComplete")) {//Voice: announce QSO logged
GeneralVariables.voiceAnnounceQsoComplete = result.equals("1");
}
if (name.equalsIgnoreCase("voiceAnnounceNewDxcc")) {//Voice: announce new-DXCC CQ
GeneralVariables.voiceAnnounceNewDxcc = result.equals("1");
}
if (name.equalsIgnoreCase("voiceAnnounceNewPrefix")) {//Voice: announce new-prefix CQ
GeneralVariables.voiceAnnounceNewPrefix = result.equals("1");
}
if (name.equalsIgnoreCase("voiceCommandsEnabled")) {//Voice: push-to-talk command button
GeneralVariables.voiceCommandsEnabled = result.equals("1");
// Hydration runs on a worker thread; postValue keeps the
// observable mirror (main-screen mic button) in sync.
GeneralVariables.mutableVoiceCommandsEnabled
.postValue(GeneralVariables.voiceCommandsEnabled);
}
if (name.equalsIgnoreCase("flexMaxRfPower")) {//Flex max RF power
GeneralVariables.flexMaxRfPower = parseConfigInt(result, 10);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.k1af.ft8af.voice;

import java.util.Locale;
import java.util.Set;

/**
* Pure, Android-free decision + dedup logic for spoken announcements (the
* voice-assistant counterpart of {@code alert/AlertDecisions}). The announcer
* itself touches TextToSpeech and can't be unit-tested; every branch here can.
*
* <p>Priority when one decode qualifies in several categories: a station
* calling ME beats everything (it needs action now), then new DXCC, then new
* prefix. New-DXCC / new-prefix announcements apply to CQ broadcasts only —
* same rule as the needed-DX notification alerts.
*
* <p>Dedup keys are namespaced strings collected in a per-session set so a
* station calling every cycle (and every decode pass within a cycle — early,
* late, deep passes all funnel through processDecodes) is announced once.
*/
public final class VoiceAnnouncementDecisions {
private VoiceAnnouncementDecisions() {}

public enum Kind { CALLING_ME, NEW_DXCC, NEW_PREFIX }

/** Whether any per-decode announcement toggle is on (cheap early-out). */
public static boolean anyDecodeAnnounceEnabled(
boolean announceCalling, boolean announceNewDxcc, boolean announceNewPrefix) {
return announceCalling || announceNewDxcc || announceNewPrefix;
}

/**
* Decide which announcement (if any) a decoded message earns.
*
* @param announceCalling the voiceAnnounceCalling user toggle
* @param announceNewDxcc the voiceAnnounceNewDxcc user toggle
* @param announceNewPrefix the voiceAnnounceNewPrefix user toggle
* @param addressedToMe message's target callsign is mine
* @param isCq message is a CQ broadcast
* @param fromNewDxcc sender is a new (unworked) DXCC entity
* @param fromNewPrefix sender carries a new (unworked) WPX prefix
* @param blocked message is filtered by the user's block list
* @return the announcement to speak, or null for silence
*/
public static Kind decide(boolean announceCalling, boolean announceNewDxcc,
boolean announceNewPrefix, boolean addressedToMe,
boolean isCq, boolean fromNewDxcc, boolean fromNewPrefix,
boolean blocked) {
if (blocked) return null;
if (announceCalling && addressedToMe) return Kind.CALLING_ME;
if (!isCq) return null;
if (announceNewDxcc && fromNewDxcc) return Kind.NEW_DXCC;
if (announceNewPrefix && fromNewPrefix) return Kind.NEW_PREFIX;
return null;
}

/**
* Claim the right to speak {@code dedupKey}, returning true only when the
* caller should proceed. The speakability gate ({@code canSpeak} — false
* while transmitting, when TTS would leak into the rig audio) is evaluated
* BEFORE the dedup set is touched: burning the key while muted would
* silence that station for the whole session, so a station first heard
* during a transmission still gets announced on its next decode.
*/
public static boolean claim(Set<String> spoken, String dedupKey, boolean canSpeak) {
if (!canSpeak) return false; // gate FIRST — do not burn the key while muted
return spoken.add(dedupKey);
}

/** One announcement per calling station per session. */
public static String callingMeKey(String fromCallsign) {
return "VCALL:" + norm(fromCallsign);
}

/** One announcement per new country per session. */
public static String newDxccKey(String country) {
return "VDXCC:" + norm(country);
}

/** One announcement per new prefix per session. */
public static String newPrefixKey(String prefix) {
return "VPREFIX:" + norm(prefix);
}

/** One announcement per logged contact (station + completion time). */
public static String qsoCompleteKey(String toCallsign, String endTime) {
return "VQSO:" + norm(toCallsign) + "|" + norm(endTime);
}

private static String norm(String s) {
// Locale.ROOT: default-locale casing (e.g. Turkish dotted/dotless I)
// would make dedup keys differ between devices for the same station.
return s == null ? "" : s.trim().toUpperCase(Locale.ROOT);
}
}
Loading
Loading