Skip to content
Draft
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
23 changes: 2 additions & 21 deletions apps/web/src/context/controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1222,7 +1222,7 @@ export const CONTROL_DEFINITIONS = [
const value =
action.op === "adjust"
? slice.nrsLevel + action.delta
: fromNormalized(action.value, 100);
: fromNormalized(action.value, 50);

slice.setNrsLevel(value);
},
Expand Down Expand Up @@ -1254,25 +1254,6 @@ export const CONTROL_DEFINITIONS = [
},
}),

defineControl<NormalizedControlAction<"slice.nrf.level", SliceTargeted>>({
target: "slice.nrf.level",
label: "Slice NRF Level",
scope: "slice",
ops: ["adjust", "set"],
editor: { kind: "normalized" },
execute(ctx, action) {
const slice = ctx.getSlice(action.slice);
if (!slice) return;

const value =
action.op === "adjust"
? slice.nrfLevel + action.delta
: fromNormalized(action.value, 100);

slice.setNrfLevel(value);
},
}),

defineControl<BooleanControlAction<"slice.nr.enabled", SliceTargeted>>({
target: "slice.nr.enabled",
label: "Slice NR Enabled",
Expand Down Expand Up @@ -2109,7 +2090,7 @@ export const ControlsProvider: ParentComponent = (props) => {

const getChoices = (target: ControlTarget, slice?: SliceSelector) => {
const definition = CONTROL_REGISTRY[target];
if (definition.editor.kind !== "choice") return [];
if (definition?.editor.kind !== "choice") return [];

const editor = definition.editor as {
kind: "choice";
Expand Down
86 changes: 54 additions & 32 deletions apps/web/src/context/flexradio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import * as ToastPrimitive from "@kobalte/core/toast";
import type {
ApdSnapshot,
AudioStreamSnapshot,
AutoDxSnapshot,
ConnectionProgressDetail,
CwDecoderSnapshot,
CwxSnapshot,
DisconnectedReason,
DisplayMarkerSnapshot,
Expand All @@ -15,7 +17,9 @@ import type {
GuiClientSnapshot,
MemorySnapshot,
MeterSnapshot,
NtpSourceSnapshot,
PanadapterSnapshot,
PresetSnapshot,
RadioSnapshot,
RadioStateChange,
SliceSnapshot,
Expand Down Expand Up @@ -131,6 +135,10 @@ export type DisplayMarkerState = Omit<
"raw"
>;
export type FilterPresetState = Omit<MutableProps<FilterPresetSnapshot>, "raw">;
export type CwDecoderState = Omit<MutableProps<CwDecoderSnapshot>, "raw">;
export type AutoDxState = Omit<MutableProps<AutoDxSnapshot>, "raw">;
export type PresetState = Omit<MutableProps<PresetSnapshot>, "raw">;
export type NtpSourceState = Omit<MutableProps<NtpSourceSnapshot>, "raw">;

export interface ConnectModalState {
status: ConnectionState;
Expand Down Expand Up @@ -159,6 +167,10 @@ export interface StatusState {
dvk: DvkState;
displayMarker: Record<string, Record<string, DisplayMarkerState>>;
filterPreset: FilterPresetState;
cwDecoder: CwDecoderState;
autoDx: AutoDxState;
preset: Record<string, PresetState>;
ntpSource: Record<string, NtpSourceState>;
}

export interface AppState {
Expand Down Expand Up @@ -200,6 +212,10 @@ export const initialState = () =>
displayMarker: {},
filterPreset: {},
waveform: {},
autoDx: {},
cwDecoder: {},
ntpSource: {},
preset: {},
},
}) as AppState;

Expand Down Expand Up @@ -386,38 +402,44 @@ export const FlexRadioProvider: ParentComponent = (props) => {
};

const handleStateChange = (change: RadioStateChange) => {
switch (change.entity) {
case "spot":
handleSpotChange(change);
break;
case "displayMarker":
handleDisplayMarkerChange(change);
break;
case "apd":
case "cwx":
case "dvk":
case "radio":
case "featureLicense":
case "filterPreset":
// singleton entities are never removed
if (!change.removed) setState("status", change.entity, change.diff);
break;
case "unknown":
console.warn("Unknown state change", change);
break;
default:
if (change.removed) {
setState(
"status",
change.entity,
produce((items) => {
delete items[change.id];
}),
);
} else {
setState("status", change.entity, change.id, change.diff);
}
break;
try {
switch (change.entity) {
case "spot":
handleSpotChange(change);
break;
case "displayMarker":
handleDisplayMarkerChange(change);
break;
case "apd":
case "autoDx":
case "cwDecoder":
case "cwx":
case "dvk":
case "featureLicense":
case "filterPreset":
case "radio":
// singleton entities are never removed
if (!change.removed) setState("status", change.entity, change.diff);
break;
case "unknown":
console.warn("Unknown state change", change);
break;
default:
if (change.removed) {
setState(
"status",
change.entity,
produce((items) => {
delete items[change.id];
}),
);
} else {
setState("status", change.entity, change.id, change.diff);
}
break;
}
} catch (err) {
console.error("Failed to update state", change, err);
}
};

Expand Down
11 changes: 10 additions & 1 deletion packages/flexlib/src/flex/apd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { type Subscription, TypedEventEmitter } from "../util/events.js";
import { formatBooleanFlag } from "./controller-helpers.js";
import { FlexStateUnavailableError } from "./errors.js";
import type { RadioSession } from "./radio-core.js";
import type { ApdSamplerPort, ApdSnapshot, ApdTxAntenna } from "./state/apd.js";
import type {
ApdLifecycleState,
ApdSamplerPort,
ApdSnapshot,
ApdTxAntenna,
} from "./state/apd.js";
import type { ApdStateChange } from "./state/index.js";

export interface ApdControllerEvents {
Expand Down Expand Up @@ -49,6 +54,10 @@ export class ApdControllerImpl implements ApdController {
return this.current().configurable;
}

get state(): ApdLifecycleState | undefined {
return this.current().state;
}

get equalizerActive(): boolean {
return this.current().equalizerActive;
}
Expand Down
100 changes: 90 additions & 10 deletions packages/flexlib/src/flex/audio-stream.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { type Subscription, TypedEventEmitter } from "../util/events.js";
import {
daxAudioClassForRate,
daxAudioSampleRateFromClass,
daxReducedBwClassForRate,
VitaDaxAudioPacket,
VitaDaxReducedBwPacket,
} from "../vita/dax-audio-packet.js";
import { VitaOpusPacket } from "../vita/opus-packet.js";
import type { VitaPacket } from "../vita/parser.js";
import { parseClientHandle } from "./client-handle.js";
import { clampInteger } from "./controller-helpers.js";
import { FlexStateUnavailableError } from "./errors.js";
import type { RadioSession } from "./radio-core.js";
Expand Down Expand Up @@ -79,8 +83,9 @@ export interface DaxRxAudioStreamController extends AudioStreamController {
/**
* Set the RX gain (0..100) for this DAX RX channel.
*
* Requires the stream to be bound to a slice. Throws
* {@link FlexStateUnavailableError} if no slice is currently bound.
* Only a stream this client owns sends the gain; for any other stream the
* call does nothing. While the stream has no bound slice, the gain is held
* and sent once a slice is bound. Only the most recent held gain is kept.
*/
setRxGain(gain: number): Promise<void>;
}
Expand All @@ -100,6 +105,7 @@ export class AudioStreamControllerImpl implements AudioStreamController {
private readonly events =
new TypedEventEmitter<AudioStreamControllerEvents>();
private streamHandle?: string;
private micRateHz = 0;
private dataListeners = 0;
private dataSubscription?: Subscription;

Expand Down Expand Up @@ -182,8 +188,24 @@ export class AudioStreamControllerImpl implements AudioStreamController {
return snapshot;
}

get sampleRateHz(): number {
switch (this.current().type) {
case "dax_rx":
case "dax_tx":
return this.followedSliceRateHz();
case "dax_mic":
return this.micRateHz;
default:
return 0;
}
}

snapshot(): AudioStreamSnapshot {
return this.current();
const current = this.current();
const sampleRateHz = this.sampleRateHz;
return sampleRateHz === current.sampleRateHz
? current
: { ...current, sampleRateHz };
}

on<TKey extends keyof AudioStreamControllerEvents>(
Expand Down Expand Up @@ -225,6 +247,28 @@ export class AudioStreamControllerImpl implements AudioStreamController {
}
}

/**
* Sample rate in Hz of the slice this stream follows, or 0 when it is not
* known. A stream bound to a slice follows that slice. A stream with no
* bound slice follows the transmit slice that its client (or its GUI client)
* owns.
*/
protected followedSliceRateHz(): number {
const snapshot = this.current();
const slices = this.radio.getStore().getSlices();
let slice = snapshot.slice
? slices.find((s) => s.indexLetter === snapshot.slice)
: undefined;
if (!slice) {
const owners = [snapshot.clientGuiHandle, snapshot.clientHandle];
slice =
slices.find(
(s) => s.isTransmitEnabled && owners.includes(s.clientHandle),
) ?? slices.find((s) => s.isTransmitEnabled);
}
return slice && slice.sampleRateHz > 0 ? slice.sampleRateHz : 0;
}

protected requireNumericStreamId(): number {
const handle = this.streamHandle ?? this.current().streamId;
const numericId = parseStreamIdentifier(handle);
Expand All @@ -243,6 +287,12 @@ export class AudioStreamControllerImpl implements AudioStreamController {
this.dataSubscription = this.radio.registerStreamHandler(
streamNumericId,
(packet) => {
if (packet.kind === "daxAudio" || packet.kind === "daxReducedBw") {
const rate = daxAudioSampleRateFromClass(
packet.classId.packetClassCode,
);
if (rate !== undefined) this.micRateHz = rate;
}
this.events.emit("data", packet);
},
);
Expand Down Expand Up @@ -273,6 +323,8 @@ export class AudioStreamTxControllerImpl

sendAudio(left: Float32Array, right: Float32Array): void {
const numericId = this.requireNumericStreamId();
const classCode = daxAudioClassForRate(this.followedSliceRateHz());
if (classCode === undefined) return;
if (!this.txAudioPkt) {
this.txAudioPkt = new VitaDaxAudioPacket();
this.txAudioPkt.streamId = numericId;
Expand All @@ -281,6 +333,7 @@ export class AudioStreamTxControllerImpl
}
const pkt = this.txAudioPkt;
pkt.streamId = numericId;
pkt.classId.packetClassCode = classCode;
pkt.header.packetCount = this.txPacketCount;
const frames = Math.min(left.length, right.length, DAX_PACKET_SAMPLES);
pkt.left.set(left.subarray(0, frames));
Expand All @@ -291,13 +344,16 @@ export class AudioStreamTxControllerImpl

sendReducedBwAudio(samples: Int16Array): void {
const numericId = this.requireNumericStreamId();
const classCode = daxReducedBwClassForRate(this.followedSliceRateHz());
if (classCode === undefined) return;
if (!this.txReducedBwPkt) {
this.txReducedBwPkt = new VitaDaxReducedBwPacket();
this.txReducedBwPkt.streamId = numericId;
this.txReducedBwPkt.samples = new Int16Array(DAX_PACKET_SAMPLES);
}
const pkt = this.txReducedBwPkt;
pkt.streamId = numericId;
pkt.classId.packetClassCode = classCode;
pkt.header.packetCount = this.txPacketCount;
const count = Math.min(samples.length, DAX_PACKET_SAMPLES);
pkt.samples.set(samples.subarray(0, count));
Expand All @@ -321,13 +377,38 @@ export class DaxRxAudioStreamControllerImpl
extends AudioStreamControllerImpl
implements DaxRxAudioStreamController
{
private pendingGain?: number;

async setRxGain(gain: number): Promise<void> {
const sliceLetter = this.slice;
if (sliceLetter === undefined) {
throw new FlexStateUnavailableError(
`DAX RX stream ${this.id} has no slice bound; cannot set gain`,
);
const clamped = clampInteger(gain, 0, 100, "DAX RX gain");
if (!this.ownedByThisClient()) return;
if (this.slice === undefined) {
this.pendingGain = clamped;
return;
}
this.pendingGain = undefined;
await this.sendGain(clamped);
}

override onStateChange(change: AudioStreamStateChange): void {
super.onStateChange(change);
if (change.removed || this.pendingGain === undefined) return;
if (this.slice === undefined || !this.ownedByThisClient()) return;
const gain = this.pendingGain;
this.pendingGain = undefined;
this.sendGain(gain).catch(() => {
// The caller already returned; a refused or unsendable gain is dropped.
});
}

private ownedByThisClient(): boolean {
const own = this.radio.clientHandle;
const ownHandle = own ? parseClientHandle(own) : undefined;
return ownHandle !== undefined && this.clientHandle === ownHandle;
}

private async sendGain(gain: number): Promise<void> {
const sliceLetter = this.slice;
const slice = this.radio
.getStore()
.getSlices()
Expand All @@ -337,9 +418,8 @@ export class DaxRxAudioStreamControllerImpl
`DAX RX stream ${this.id} bound to slice ${sliceLetter} but slice is not in store`,
);
}
const clamped = clampInteger(gain, 0, 100, "DAX RX gain");
await this.radio.command(
`audio stream ${this.id} slice ${slice.id} gain ${clamped}`,
`audio stream ${this.id} slice ${slice.id} gain ${gain}`,
);
}
}
Expand Down
Loading
Loading