Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion integration-tests/qwen-live-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const SERVE_TOKEN = 'qwen-live-e2e-token';
const LIVE_LISTENING_RE = /qwen-live listening on http:\/\/127\.0\.0\.1:(\d+)/;
const DISPOSE_GRACE_MS = 10_000;
const LIVE_HOST_BUNDLE_ID = 'com.alibaba.qwen-code.live-host';
const LIVE_HOST_PROTOCOL_VERSION = 6;
const LIVE_HOST_PROTOCOL_VERSION = 7;
const LIVE_INPUT_AUDIO_EPOCH_BYTES = 8;

// -- small async utilities ----------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion integration-tests/qwen-live-m1-call.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ describeE2E('qwen-live M1 — end-to-end voice call', () => {
expect(record['url']).toBe(stack.live.url);
expect(typeof record['token']).toBe('string');
expect(String(record['token']).length).toBeGreaterThan(0);
expect(record['protocolVersion']).toBe(6);
expect(record['protocolVersion']).toBe(7);
expect(record['pid']).toBe(stack.live.proc.pid);
expect(String(record['instanceNonce'])).toMatch(/^[A-Za-z0-9_-]{16,256}$/);
});
Expand Down
3 changes: 3 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions packages/cli/src/serve/live/live-host-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,18 @@ function parseHostMessage(text: string): LiveHostMessage | undefined {
};
}
}
if (
value['type'] === 'host.playback_started' &&
typeof value['epoch'] === 'number'
) {
return { type: 'host.playback_started', epoch: value['epoch'] };
}
if (
value['type'] === 'host.playback_completed' &&
typeof value['epoch'] === 'number'
) {
return { type: 'host.playback_completed', epoch: value['epoch'] };
}
return undefined;
}

Expand Down Expand Up @@ -983,6 +995,15 @@ export class LiveHostCoordinator {
this.handleShortcutResult(message);
return;
}
// v7 playback receipts: accepted but not forwarded to the built-in
// Live session coordinator (which uses byte estimation). The
// standalone qwen-live daemon wires these to its injector.
if (
message.type === 'host.playback_started' ||
message.type === 'host.playback_completed'
) {
return;
}
this.handleAction(message);
}

Expand Down
16 changes: 14 additions & 2 deletions packages/cli/src/serve/live/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

export const LIVE_HOST_PROTOCOL_VERSION = 6 as const;
export const LIVE_HOST_PROTOCOL_VERSION = 7 as const;
export const LIVE_HOST_BUNDLE_ID = 'com.alibaba.qwen-code.live-host' as const;
export const LIVE_INPUT_AUDIO_EPOCH_BYTES = 8;

Expand Down Expand Up @@ -149,12 +149,24 @@ export type LiveHostScreenContextResult =
error: string;
};

export interface LiveHostPlaybackStarted {
type: 'host.playback_started';
epoch: number;
}

export interface LiveHostPlaybackCompleted {
type: 'host.playback_completed';
epoch: number;
}

export type LiveHostMessage =
| LiveHostHello
| LiveHostAction
| LiveHostPong
| LiveHostShortcutResult
| LiveHostScreenContextResult;
| LiveHostScreenContextResult
| LiveHostPlaybackStarted
| LiveHostPlaybackCompleted;

export type LiveDaemonMessage =
| {
Expand Down
2 changes: 1 addition & 1 deletion packages/live-host/src/main/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ describe('Live Host protocol', () => {
/LIVE_HOST_BUNDLE_ID = '([^']+)'/u,
)?.[1];

assert.equal(LIVE_PROTOCOL_VERSION, 6);
assert.equal(LIVE_PROTOCOL_VERSION, 7);
assert.equal(daemonVersion, LIVE_PROTOCOL_VERSION);
assert.equal(daemonBundleId, LIVE_HOST_BUNDLE_ID);
assert.equal(Object.values(PROTOCOL_TYPE_PARITY).every(Boolean), true);
Expand Down
8 changes: 8 additions & 0 deletions packages/live-host/src/main/daemon-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,14 @@ export class LiveDaemonConnection {
return true;
}

sendPlaybackStarted(epoch: number): boolean {
return this.sendControl({ type: 'host.playback_started', epoch });
}

sendPlaybackCompleted(epoch: number): boolean {
return this.sendControl({ type: 'host.playback_completed', epoch });
}

private async captureScreenContext(
requestId: string,
epoch: number,
Expand Down
29 changes: 26 additions & 3 deletions packages/live-host/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,28 @@ function registerIpc(): void {
});
publishState();
});
ipcMain.on('live:audio:playback-started', (_event, epoch: unknown) => {
if (
typeof epoch !== 'number' ||
!Number.isSafeInteger(epoch) ||
epoch !== daemon.getEpoch()
) {
return;
}
daemon.sendPlaybackStarted(epoch);
});

ipcMain.on('live:audio:playback-completed', (_event, epoch: unknown) => {
if (
typeof epoch !== 'number' ||
!Number.isSafeInteger(epoch) ||
epoch !== daemon.getEpoch()
) {
return;
}
daemon.sendPlaybackCompleted(epoch);
});

ipcMain.handle('live:set-output-muted', (event, muted: unknown) => {
if (!isTrustedSender(event) || typeof muted !== 'boolean') return;
const inputMuted = live.inputMuted ?? false;
Expand Down Expand Up @@ -970,12 +992,13 @@ void app.whenReady().then(() => {
},
onOutputAudio: (audio) => {
if (nativeServicesActive && !live.outputMuted) {
appendHostAudio(audio, daemon.getEpoch());
const epoch = daemon.getEpoch();
appendHostAudio(audio, epoch);
writeLiveDiagnostic('output_frame_received', {
epoch: daemon.getEpoch(),
epoch,
bytes: audio.byteLength,
});
sendAudioCommand('live:audio:play', audio);
sendAudioCommand('live:audio:play', { audio, epoch });
}
},
onClearOutput: () => {
Expand Down
15 changes: 15 additions & 0 deletions packages/live-host/src/preload/audio-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export class HostAudioEngine {
event: string,
details: AudioDiagnosticDetails,
) => void = () => {},
private readonly onPlaybackStarted: () => void = () => {},
private readonly onPlaybackCompleted: () => void = () => {},
) {}

private readonly handleDeviceChange = (): void => {
Expand Down Expand Up @@ -255,10 +257,23 @@ export class HostAudioEngine {
currentGeneration: this.outputGeneration,
remainingSources: this.outputSources.size,
});
// Only fire completion for a natural end (generation matches);
// clearOutput increments generation before stopping sources,
// so a stop-triggered onended sees a mismatch and stays silent.
if (
this.outputSources.size === 0 &&
generation === this.outputGeneration
) {
this.onPlaybackCompleted();
}
};
const wasEmpty = this.outputSources.size === 0;
this.outputSources.add(source);
source.start(schedule.startAt);
this.outputCursor = schedule.endAt;
if (wasEmpty) {
this.onPlaybackStarted();
}
this.onDiagnostic('output_frame_scheduled', {
bytes: frame.byteLength,
generation,
Expand Down
29 changes: 22 additions & 7 deletions packages/live-host/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ const audio = new HostAudioEngine(
ipcRenderer.send('live:audio:diagnostic', { event, details });
}
},
() => {
if (currentPlaybackEpoch !== undefined) {
ipcRenderer.send('live:audio:playback-started', currentPlaybackEpoch);
}
},
() => {
if (currentPlaybackEpoch !== undefined) {
ipcRenderer.send('live:audio:playback-completed', currentPlaybackEpoch);
}
},
);

const invoke = (channel: string, ...args: unknown[]): Promise<void> =>
Expand Down Expand Up @@ -85,14 +95,19 @@ ipcRenderer.on(
ipcRenderer.on('live:audio:set-output-muted', (_event, muted: boolean) => {
audio.setOutputMuted(muted);
});
ipcRenderer.on('live:audio:play', (_event, frame: Uint8Array) => {
void audio.play(frame).catch(() => {
audio.clearOutput();
ipcRenderer.send('live:audio:output-error', {
code: 'audio_output_unavailable',
let currentPlaybackEpoch: number | undefined;
ipcRenderer.on(
'live:audio:play',
(_event, payload: { audio: Uint8Array; epoch: number }) => {
currentPlaybackEpoch = payload.epoch;
void audio.play(payload.audio).catch(() => {
audio.clearOutput();
ipcRenderer.send('live:audio:output-error', {
code: 'audio_output_unavailable',
});
});
});
});
},
);
ipcRenderer.on('live:audio:clear', () => audio.clearOutput());

let lastPointerInteractive = false;
Expand Down
6 changes: 4 additions & 2 deletions packages/live-host/src/shared/protocol.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const LIVE_PROTOCOL_VERSION = 6;
export const LIVE_PROTOCOL_VERSION = 7;
export const LIVE_HOST_BUNDLE_ID = 'com.alibaba.qwen-code.live-host';
export const MAX_CONTROL_FRAME_BYTES = 64 * 1024;
export const MAX_INPUT_AUDIO_FRAME_BYTES = 64 * 1024;
Expand Down Expand Up @@ -112,7 +112,9 @@ export type HostControlMessage =
requestId: string;
success: false;
error: string;
};
}
| { type: 'host.playback_started'; epoch: number }
| { type: 'host.playback_completed'; epoch: number };

export type DaemonControlMessage =
| {
Expand Down
Loading
Loading