dsh-speak 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine/speak.ps1 CHANGED
@@ -1,76 +1,76 @@
1
- # speak.ps1 — Harness-agnostic speech engine (Windows SAPI5 + NaturalVoiceSAPIAdapter)
2
- # ====================================================================================
3
- # Reads text (inline or from a UTF-8 file), cleans it for speech synthesis, and reads
4
- # it aloud through Windows SAPI5, preferring natural voices registered by
5
- # NaturalVoiceSAPIAdapter (https://github.com/gexgd0419/NaturalVoiceSAPIAdapter).
6
- #
7
- # This script knows NOTHING about any harness (DSH, Claude Code, ...). Any process
8
- # can call it:
9
- #
10
- # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speak.ps1 -Text "hello"
11
- # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speak.ps1 -File C:\tmp\msg.txt
12
- #
13
- # It is best-effort by design: it never throws, never blocks the caller for longer
14
- # than the utterance itself, and exits 0 even if something failed.
15
- #
16
- # Design notes (see docs/DESIGN.md for full rationale):
17
- # * Markdown symbols, URLs and emoji are stripped before speaking — SAPI5 Speak()
18
- # silently fails (produces no audio, no error) when it hits emoji/surrogates.
19
- # * NaturalVoiceSAPIAdapter has a per-Speak character ceiling (~375-470 chars);
20
- # beyond that it silently speaks nothing. Text longer than $MaxChars is replaced
21
- # with $LongTextMessage instead.
22
- # * Adapter-registered voices often have plain names ("Microsoft Xiaoxiao") that do
23
- # not contain the word "Natural", so matching checks Name + Description.
24
- # ====================================================================================
25
-
26
- param(
27
- [string]$Text = '',
28
- [string]$File = '',
29
- [int]$Volume = 50,
30
- [int]$Rate = 1,
31
- [int]$MaxChars = 300,
32
- [string]$LongTextMessage = '本次播报内容较长,请自行阅读。'
33
- )
34
-
35
- # ---------- input: pick text source ----------
36
- if ($File) {
37
- if (-not (Test-Path $File)) { exit 0 }
38
- $text = [System.IO.File]::ReadAllText($File, [System.Text.Encoding]::UTF8)
39
- } else {
40
- $text = [string]$Text
41
- }
42
- if (-not $text -or -not $text.Trim()) { exit 0 }
43
-
44
- # ---------- clean: markdown -> plain speech text ----------
45
- # code blocks, inline code, markdown links, bare URLs, emphasis/marker chars
46
- $text = $text -replace '```[\s\S]*?```', ' '
47
- $text = $text -replace '`[^`]*`', ' '
48
- $text = $text -replace '\[([^\]]*)\]\([^\)]*\)', '$1'
49
- $text = $text -replace 'https?://\S+', ' '
50
- $text = $text -replace '[-#*_~|>+]+', ' '
51
- # emoji / special symbols (Speak() fails silently on them): keep CJK, CJK punct,
52
- # full-width ranges, ASCII printable
53
- $text = [regex]::Replace($text, '[^一-龥 -〿＀-￯ - -~]', '')
54
- $text = $text -replace '\s+', ' '
55
- $text = $text.Trim()
56
-
57
- # ---------- length guard: adapter per-Speak ceiling ----------
58
- if ($text.Length -gt $MaxChars) { $text = $LongTextMessage }
59
-
60
- # ---------- speak ----------
61
- Add-Type -AssemblyName System.Speech
62
- $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
63
- $synth.Volume = $Volume
64
-
65
- # prefer a zh natural voice (NaturalVoiceSAPIAdapter-registered), fall back to any zh
66
- $voices = $synth.GetInstalledVoices()
67
- $voice = $voices | Where-Object {
68
- $_.VoiceInfo.Culture.Name -like 'zh*' -and
69
- ($_.VoiceInfo.Name + ' ' + $_.VoiceInfo.Description) -match 'Natural|Online'
70
- } | Select-Object -First 1
71
- if (-not $voice) { $voice = $voices | Where-Object { $_.VoiceInfo.Culture.Name -like 'zh*' } | Select-Object -First 1 }
72
- if ($voice) { $synth.SelectVoice($voice.VoiceInfo.Name) }
73
-
74
- $synth.Rate = $Rate
75
- $synth.Speak($text)
76
- exit 0
1
+ # speak.ps1 — Harness-agnostic speech engine (Windows SAPI5 + NaturalVoiceSAPIAdapter)
2
+ # ====================================================================================
3
+ # Reads text (inline or from a UTF-8 file), cleans it for speech synthesis, and reads
4
+ # it aloud through Windows SAPI5, preferring natural voices registered by
5
+ # NaturalVoiceSAPIAdapter (https://github.com/gexgd0419/NaturalVoiceSAPIAdapter).
6
+ #
7
+ # This script knows NOTHING about any harness (DSH, Claude Code, ...). Any process
8
+ # can call it:
9
+ #
10
+ # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speak.ps1 -Text "hello"
11
+ # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speak.ps1 -File C:\tmp\msg.txt
12
+ #
13
+ # It is best-effort by design: it never throws, never blocks the caller for longer
14
+ # than the utterance itself, and exits 0 even if something failed.
15
+ #
16
+ # Design notes (see docs/DESIGN.md for full rationale):
17
+ # * Markdown symbols, URLs and emoji are stripped before speaking — SAPI5 Speak()
18
+ # silently fails (produces no audio, no error) when it hits emoji/surrogates.
19
+ # * NaturalVoiceSAPIAdapter has a per-Speak character ceiling (~375-470 chars);
20
+ # beyond that it silently speaks nothing. Text longer than $MaxChars is replaced
21
+ # with $LongTextMessage instead.
22
+ # * Adapter-registered voices often have plain names ("Microsoft Xiaoxiao") that do
23
+ # not contain the word "Natural", so matching checks Name + Description.
24
+ # ====================================================================================
25
+
26
+ param(
27
+ [string]$Text = '',
28
+ [string]$File = '',
29
+ [int]$Volume = 50,
30
+ [int]$Rate = 1,
31
+ [int]$MaxChars = 300,
32
+ [string]$LongTextMessage = '本次播报内容较长,请自行阅读。'
33
+ )
34
+
35
+ # ---------- input: pick text source ----------
36
+ if ($File) {
37
+ if (-not (Test-Path $File)) { exit 0 }
38
+ $text = [System.IO.File]::ReadAllText($File, [System.Text.Encoding]::UTF8)
39
+ } else {
40
+ $text = [string]$Text
41
+ }
42
+ if (-not $text -or -not $text.Trim()) { exit 0 }
43
+
44
+ # ---------- clean: markdown -> plain speech text ----------
45
+ # code blocks, inline code, markdown links, bare URLs, emphasis/marker chars
46
+ $text = $text -replace '```[\s\S]*?```', ' '
47
+ $text = $text -replace '`[^`]*`', ' '
48
+ $text = $text -replace '\[([^\]]*)\]\([^\)]*\)', '$1'
49
+ $text = $text -replace 'https?://\S+', ' '
50
+ $text = $text -replace '[-#*_~|>+]+', ' '
51
+ # emoji / special symbols (Speak() fails silently on them): keep CJK, CJK punct,
52
+ # full-width ranges, ASCII printable
53
+ $text = [regex]::Replace($text, '[^一-龥 -〿＀-￯ - -~]', '')
54
+ $text = $text -replace '\s+', ' '
55
+ $text = $text.Trim()
56
+
57
+ # ---------- length guard: adapter per-Speak ceiling ----------
58
+ if ($text.Length -gt $MaxChars) { $text = $LongTextMessage }
59
+
60
+ # ---------- speak ----------
61
+ Add-Type -AssemblyName System.Speech
62
+ $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
63
+ $synth.Volume = $Volume
64
+
65
+ # prefer a zh natural voice (NaturalVoiceSAPIAdapter-registered), fall back to any zh
66
+ $voices = $synth.GetInstalledVoices()
67
+ $voice = $voices | Where-Object {
68
+ $_.VoiceInfo.Culture.Name -like 'zh*' -and
69
+ ($_.VoiceInfo.Name + ' ' + $_.VoiceInfo.Description) -match 'Natural|Online'
70
+ } | Select-Object -First 1
71
+ if (-not $voice) { $voice = $voices | Where-Object { $_.VoiceInfo.Culture.Name -like 'zh*' } | Select-Object -First 1 }
72
+ if ($voice) { $synth.SelectVoice($voice.VoiceInfo.Name) }
73
+
74
+ $synth.Rate = $Rate
75
+ $synth.Speak($text)
76
+ exit 0
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env bash
2
+ # speak.sh — macOS speech engine (uses the built-in `say` command)
3
+ # ==============================================================================
4
+ # The macOS counterpart of engine/speak.ps1. Reads text (inline or UTF-8 file),
5
+ # cleans it for speech synthesis, and reads it aloud via the system `say`
6
+ # command. Any process can call it:
7
+ #
8
+ # ./speak.sh -t "你好,构建完成"
9
+ # ./speak.sh -f /tmp/msg.txt -v Eddy -r 200
10
+ #
11
+ # Best-effort by design: never throws, exits 0 even if something failed.
12
+ #
13
+ # Voice selection:
14
+ # * no -v (default): follow the system voice — on recent macOS this is the
15
+ # Siri voice chosen in Settings > Siri > Voice (e.g. "声音 1"), on older
16
+ # versions the Spoken Content voice. This is the least surprising default.
17
+ # * -v <name>: force a voice by name (e.g. Eddy, Flo, Tingting — see
18
+ # `say -v '?'`).
19
+ # * NOTE: the Siri voices ("声音 1-4") are NOT exposed to `say` — they do not
20
+ # appear in `say -v '?'` and cannot be selected by name; they only work as
21
+ # the system default.
22
+ #
23
+ # Notes:
24
+ # * LC_ALL is pinned to a UTF-8 locale so the perl cleaning pipeline and bash
25
+ # character counting behave identically regardless of the caller's locale
26
+ # (a C/POSIX locale would silently strip all CJK text).
27
+ # * The cleaning pipeline mirrors speak.ps1 (markdown/URL/emoji stripped).
28
+ # * `say` has no volume flag — volume is controlled by the system output.
29
+ # * Length guard: text over $MAX_CHARS is replaced with $LONG_MSG.
30
+ # ==============================================================================
31
+
32
+ export LC_ALL="${LC_ALL:-en_US.UTF-8}"
33
+
34
+ TEXT=""
35
+ FILE=""
36
+ VOICE=""
37
+ RATE=175 # words per minute (say default)
38
+ MAX_CHARS=300
39
+ LONG_MSG="本次播报内容较长,请自行阅读。"
40
+
41
+ usage() {
42
+ echo "usage: speak.sh [-t text | -f file] [-v voice] [-r wpm] [-m maxchars] [-l longmsg]" >&2
43
+ exit 1
44
+ }
45
+
46
+ while getopts "t:f:v:r:m:l:h" opt; do
47
+ case "$opt" in
48
+ t) TEXT="$OPTARG" ;;
49
+ f) FILE="$OPTARG" ;;
50
+ v) VOICE="$OPTARG" ;;
51
+ r) RATE="$OPTARG" ;;
52
+ m) MAX_CHARS="$OPTARG" ;;
53
+ l) LONG_MSG="$OPTARG" ;;
54
+ h) usage ;;
55
+ *) usage ;;
56
+ esac
57
+ done
58
+
59
+ # ---------- input ----------
60
+ if [ -n "$FILE" ]; then
61
+ [ -f "$FILE" ] || exit 0
62
+ TEXT=$(/usr/bin/perl -CSD -e 'print <>' "$FILE")
63
+ fi
64
+ if [ -z "$TEXT" ]; then exit 0; fi
65
+
66
+ # ---------- clean (mirrors speak.ps1) ----------
67
+ TEXT=$(printf '%s' "$TEXT" | /usr/bin/perl -CSD -pe '
68
+ s/```[\s\S]*?```/ /g; # code blocks
69
+ s/`[^`]*`/ /g; # inline code
70
+ s/\[([^\]]*)\]\([^\)]*\)/$1/g; # markdown links
71
+ s|https?://\S+| |g; # bare URLs
72
+ s/[-#*_~|>+]+/ /g; # emphasis / marker chars
73
+ s/[^\p{Han}\x{3000}-\x{303F}\x{FF00}-\x{FFEF}\x{2000}-\x{206F}\x{20}-\x{7E}]//g; # emoji / specials
74
+ s/\s+/ /g; # collapse whitespace
75
+ ')
76
+ TEXT=$(printf '%s' "$TEXT" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
77
+
78
+ # ---------- length guard ----------
79
+ if [ "${#TEXT}" -gt "$MAX_CHARS" ]; then
80
+ TEXT="$LONG_MSG"
81
+ fi
82
+
83
+ # ---------- speak (no -v => system default voice) ----------
84
+ if [ -n "$VOICE" ]; then
85
+ say -v "$VOICE" -r "$RATE" "$TEXT"
86
+ else
87
+ say -r "$RATE" "$TEXT"
88
+ fi
89
+ exit 0
@@ -1,22 +1,22 @@
1
- # speech-prompt.ps1 — Short prompt announcement (synchronous, blocking)
2
- # Use when a harness/agent needs the user's attention (a question, an approval
3
- # request). Reads the text through engine/speak.ps1 and waits until it finishes,
4
- # so the caller knows the announcement was actually spoken.
5
- #
6
- # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speech-prompt.ps1 -Text "请做出选择"
7
-
8
- param([string]$Text = '请做出选择')
9
-
10
- if (-not $Text) { exit 0 }
11
-
12
- $speak = Join-Path $PSScriptRoot 'speak.ps1'
13
- if (-not (Test-Path $speak)) { exit 0 }
14
-
15
- $tmp = Join-Path $env:TEMP ('speech-prompt-' + [guid]::NewGuid().ToString('N') + '.txt')
16
- try {
17
- [System.IO.File]::WriteAllText($tmp, $Text, [System.Text.UTF8Encoding]::new($false))
18
- & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $speak -File $tmp
19
- exit $LASTEXITCODE
20
- } finally {
21
- if (Test-Path $tmp) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue }
22
- }
1
+ # speech-prompt.ps1 — Short prompt announcement (synchronous, blocking)
2
+ # Use when a harness/agent needs the user's attention (a question, an approval
3
+ # request). Reads the text through engine/speak.ps1 and waits until it finishes,
4
+ # so the caller knows the announcement was actually spoken.
5
+ #
6
+ # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speech-prompt.ps1 -Text "请做出选择"
7
+
8
+ param([string]$Text = '请做出选择')
9
+
10
+ if (-not $Text) { exit 0 }
11
+
12
+ $speak = Join-Path $PSScriptRoot 'speak.ps1'
13
+ if (-not (Test-Path $speak)) { exit 0 }
14
+
15
+ $tmp = Join-Path $env:TEMP ('speech-prompt-' + [guid]::NewGuid().ToString('N') + '.txt')
16
+ try {
17
+ [System.IO.File]::WriteAllText($tmp, $Text, [System.Text.UTF8Encoding]::new($false))
18
+ & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $speak -File $tmp
19
+ exit $LASTEXITCODE
20
+ } finally {
21
+ if (Test-Path $tmp) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue }
22
+ }
@@ -1,27 +1,27 @@
1
- # speech-summary.ps1 — Reply summary announcement (synchronous, blocking)
2
- # For harnesses with no "reply finished" event (e.g. DSH has no Stop hook): the
3
- # agent calls this at the end of its final reply.
4
- #
5
- # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speech-summary.ps1 -Text "总结文本"
6
- #
7
- # NOTE: keep this SYNCHRONOUS. An earlier version spawned the inner powershell
8
- # asynchronously with Start-Process; DSH's sandbox blocks nested sub-process
9
- # spawning, so it silently produced no audio. The synchronous call chain
10
- # (summary -> speak.ps1) is the reliable path (cost: caller waits for the
11
- # utterance to finish).
12
-
13
- param([string]$Text = '')
14
-
15
- if (-not $Text) { exit 0 }
16
-
17
- $speak = Join-Path $PSScriptRoot 'speak.ps1'
18
- if (-not (Test-Path $speak)) { exit 0 }
19
-
20
- $tmp = Join-Path $env:TEMP ('speech-summary-' + [guid]::NewGuid().ToString('N') + '.txt')
21
- try {
22
- [System.IO.File]::WriteAllText($tmp, $Text, [System.Text.UTF8Encoding]::new($false))
23
- & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $speak -File $tmp
24
- exit $LASTEXITCODE
25
- } finally {
26
- if (Test-Path $tmp) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue }
27
- }
1
+ # speech-summary.ps1 — Reply summary announcement (synchronous, blocking)
2
+ # For harnesses with no "reply finished" event (e.g. DSH has no Stop hook): the
3
+ # agent calls this at the end of its final reply.
4
+ #
5
+ # powershell.exe -NoProfile -ExecutionPolicy Bypass -File speech-summary.ps1 -Text "总结文本"
6
+ #
7
+ # NOTE: keep this SYNCHRONOUS. An earlier version spawned the inner powershell
8
+ # asynchronously with Start-Process; DSH's sandbox blocks nested sub-process
9
+ # spawning, so it silently produced no audio. The synchronous call chain
10
+ # (summary -> speak.ps1) is the reliable path (cost: caller waits for the
11
+ # utterance to finish).
12
+
13
+ param([string]$Text = '')
14
+
15
+ if (-not $Text) { exit 0 }
16
+
17
+ $speak = Join-Path $PSScriptRoot 'speak.ps1'
18
+ if (-not (Test-Path $speak)) { exit 0 }
19
+
20
+ $tmp = Join-Path $env:TEMP ('speech-summary-' + [guid]::NewGuid().ToString('N') + '.txt')
21
+ try {
22
+ [System.IO.File]::WriteAllText($tmp, $Text, [System.Text.UTF8Encoding]::new($false))
23
+ & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $speak -File $tmp
24
+ exit $LASTEXITCODE
25
+ } finally {
26
+ if (Test-Path $tmp) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue }
27
+ }
package/package.json CHANGED
@@ -1,43 +1,46 @@
1
- {
2
- "name": "dsh-speak",
3
- "version": "1.0.0",
4
- "description": "Make your AI harness speak — verified voice announcements for DSH and other AI coding harnesses (Windows SAPI5 + natural voices)",
5
- "main": "adapters/dsh/speech-hook.js",
6
- "files": [
7
- "adapters/dsh/speech-hook.js",
8
- "adapters/dsh/install.ps1",
9
- "engine/",
10
- "docs/",
11
- "README.md",
12
- "README.zh-CN.md",
13
- "LICENSE"
14
- ],
15
- "keywords": [
16
- "dsh",
17
- "dsh-plugin",
18
- "deepseek-harness",
19
- "tts",
20
- "text-to-speech",
21
- "voice",
22
- "speech",
23
- "voice-announcement",
24
- "sapi5",
25
- "windows",
26
- "powershell"
27
- ],
28
- "scripts": {
29
- "prepublishOnly": "node --check adapters/dsh/speech-hook.js"
30
- },
31
- "engines": {
32
- "node": ">=18"
33
- },
34
- "repository": {
35
- "type": "git",
36
- "url": "https://github.com/Alan2Z/dsh-speak.git"
37
- },
38
- "author": "Alan2Z",
39
- "license": "MIT",
40
- "publishConfig": {
41
- "registry": "https://registry.npmjs.org"
42
- }
43
- }
1
+ {
2
+ "name": "dsh-speak",
3
+ "version": "1.2.0",
4
+ "description": "Make your AI harness speak — verified voice announcements for DSH and other AI coding harnesses (Windows SAPI5 + macOS system voices)",
5
+ "main": "adapters/dsh/speech-hook.js",
6
+ "files": [
7
+ "adapters/dsh/speech-hook.js",
8
+ "adapters/dsh/install.ps1",
9
+ "engine/",
10
+ "docs/",
11
+ "README.md",
12
+ "README.zh-CN.md",
13
+ "LICENSE"
14
+ ],
15
+ "keywords": [
16
+ "dsh",
17
+ "dsh-plugin",
18
+ "deepseek-harness",
19
+ "tts",
20
+ "text-to-speech",
21
+ "voice",
22
+ "speech",
23
+ "voice-announcement",
24
+ "sapi5",
25
+ "windows",
26
+ "powershell",
27
+ "macos",
28
+ "darwin",
29
+ "say"
30
+ ],
31
+ "scripts": {
32
+ "prepublishOnly": "node --check adapters/dsh/speech-hook.js"
33
+ },
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/Alan2Z/dsh-speak.git"
40
+ },
41
+ "author": "Alan2Z",
42
+ "license": "MIT",
43
+ "publishConfig": {
44
+ "registry": "https://registry.npmjs.org"
45
+ }
46
+ }