Simplify Windows TTS playback path

This commit is contained in:
2026-05-01 03:38:30 +09:00
parent 0a88e8dab1
commit 1a8e8d0a8f
3 changed files with 54 additions and 35 deletions

View File

@@ -511,6 +511,11 @@ export class LocalVoiceSession {
} }
private async playToWindowsDefaultSink(playback: PreparedSpeechAudio, signal: AbortSignal): Promise<void> { private async playToWindowsDefaultSink(playback: PreparedSpeechAudio, signal: AbortSignal): Promise<void> {
if (playback.sourceFilePath) {
await this.playWindowsWaveFile(playback.sourceFilePath, signal);
return;
}
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -584,6 +589,47 @@ export class LocalVoiceSession {
} }
} }
private async playWindowsWaveFile(filePath: string, signal: AbortSignal): Promise<void> {
const psScript = [
"Add-Type -AssemblyName System;",
`$player = New-Object System.Media.SoundPlayer('${filePath.replace(/'/g, "''")}');`,
"$player.PlaySync();",
].join(" ");
const player = spawn("powershell", ["-NoProfile", "-Command", psScript], {
stdio: ["ignore", "ignore", "pipe"],
});
this.currentPlayer = player;
player.stderr.on("data", (chunk: Buffer) => {
const text = chunk.toString().trim();
if (text.length > 0) {
this.options.logger.debug("[powershell-player]", text);
}
});
signal.addEventListener(
"abort",
() => {
if (!player.killed) {
player.kill("SIGKILL");
}
},
{ once: true },
);
const [code, playSignal] = (await once(player, "exit")) as [number | null, NodeJS.Signals | null];
this.currentPlayer = null;
if (signal.aborted) {
return;
}
if (code !== 0) {
throw new Error(`powershell playback exited with code=${code ?? "null"} signal=${playSignal ?? "null"}`);
}
}
private getFfmpegPath(): string { private getFfmpegPath(): string {
return requireFfmpegPath(); return requireFfmpegPath();
} }

View File

@@ -2,6 +2,7 @@ import type { Readable } from "node:stream";
export interface PreparedSpeechAudio { export interface PreparedSpeechAudio {
stream: Readable; stream: Readable;
sourceFilePath?: string;
dispose: () => void; dispose: () => void;
} }

View File

@@ -4,8 +4,6 @@ import { unlink } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import prism from "prism-media";
import { resolveFfmpegPath } from "../audio/ffmpeg-path.js"; import { resolveFfmpegPath } from "../audio/ffmpeg-path.js";
import type { PreparedSpeechAudio, TtsService } from "./tts.js"; import type { PreparedSpeechAudio, TtsService } from "./tts.js";
@@ -90,45 +88,19 @@ export class WindowsSystemTtsService implements TtsService {
throw error; throw error;
}); });
const input = createReadStream(tempPath);
const ffmpeg = new prism.FFmpeg({
args: [
"-analyzeduration",
"0",
"-loglevel",
"0",
"-i",
tempPath,
"-f",
"s16le",
"-ar",
"48000",
"-ac",
"2",
"pipe:1",
],
});
signal?.addEventListener(
"abort",
() => {
input.destroy();
ffmpeg.destroy();
void unlink(tempPath).catch(() => null);
},
{ once: true },
);
return { return {
stream: ffmpeg, stream: createReadStream(tempPath),
sourceFilePath: tempPath,
dispose: () => { dispose: () => {
input.destroy(); this.cleanupTempWave(tempPath);
ffmpeg.destroy();
void unlink(tempPath).catch(() => null);
}, },
}; };
} }
private cleanupTempWave(filePath: string): void {
void unlink(filePath).catch(() => null);
}
async destroy(): Promise<void> { async destroy(): Promise<void> {
return; return;
} }