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
39 changes: 39 additions & 0 deletions .github/workflows/live-host-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,45 @@ jobs:
gh release create "$FEED_TAG" "${stable_assets[@]}" --title 'Qwen Live Host latest' --notes 'Stable Qwen Live Host installer feed.' --latest=false
fi

- name: 'Checkout source for qwen-live npm publish'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10'

- name: 'Set up Node.js'
uses: 'actions/setup-node@49933f5360751b6f8e5e4b6c6f3a8c3c5c5c5c5c'
with:
node-version-file: '.nvmrc'
registry-url: 'https://registry.npmjs.org'

- name: 'Install dependencies'
run: 'npm install --ignore-scripts'

- name: 'Build qwen-live'
run: 'npm run build --workspace @qwen-code/qwen-live'

- name: 'Publish @qwen-code/qwen-live'
working-directory: 'packages/qwen-live'
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
RELEASE_VERSION: '${{ needs.prepare.outputs.version }}'
NPM_TAG: "${{ inputs.prerelease == true && 'preview' || 'latest' }}"
run: |
set -euo pipefail
PACKAGE_NAME="$(node -p "require('./package.json').name")"
# Align the package version with the Host release version.
npm version "$RELEASE_VERSION" --no-git-tag-version --allow-same-version
if npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version >/dev/null 2>&1; then
echo "::notice::${PACKAGE_NAME}@${RELEASE_VERSION} already published; skipping"
exit 0
fi
# Check if the package exists at all — provenance requires an
# existing package, so the first publish omits it.
if npm view "${PACKAGE_NAME}" version >/dev/null 2>&1; then
npm publish --access public --provenance --tag "$NPM_TAG"
else
echo "::notice::First publish of ${PACKAGE_NAME}; omitting --provenance"
npm publish --access public --tag "$NPM_TAG"
fi

- name: 'Publish release summary'
env:
RELEASE_URL: '${{ steps.release.outputs.url }}'
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,4 @@ tmp/

# Brand build workspaces (created by the desktop-brand-builder skill)
brand-builds/
workspace/
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
31 changes: 28 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,30 @@ function registerIpc(): void {
});
publishState();
});
ipcMain.on('live:audio:playback-started', (event, epoch: unknown) => {
if (
!isTrustedSender(event) ||
typeof epoch !== 'number' ||
!Number.isSafeInteger(epoch) ||
epoch !== daemon.getEpoch()
) {
return;
}
daemon.sendPlaybackStarted(epoch);
});

ipcMain.on('live:audio:playback-completed', (event, epoch: unknown) => {
if (
!isTrustedSender(event) ||
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 +994,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