├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE.md ├── Makefile ├── README.md ├── bin └── voices ├── doc └── voices.md ├── man └── voices.1 ├── osx-service ├── README.md ├── Speak With Specific Voice.workflow.zip ├── Speak With Specific Voice.workflow │ └── Contents │ │ ├── Info.plist │ │ └── document.wflow ├── Switch Default Voice.workflow.zip └── Switch Default Voice.workflow │ └── Contents │ ├── Info.plist │ ├── document.wflow │ └── net.same2u │ ├── .SwitchDefaultVoice-rc │ └── voices ├── package.json └── test ├── 0 Prerequisites ├── Correctly reports default voice ├── Option -L with -a reports all installed languages ├── Option -d changes default voice ├── Option -l reports subset of installed voices ├── Option -l with -a reports all installed voices ├── Option -l with language filter works correctly ├── Option -m opens the speech System Preferences pane ├── README.md ├── standard CLI options ├── Option --version prints version └── Options -h and --help print CLI help └── syntax └── Unknown options cause an error /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # npm's debug files, created when something goes wrong. 3 | npm-debug.log 4 | 5 | # npm modules 6 | node_modules/ 7 | 8 | # urchin log files 9 | .urchin.log 10 | 11 | # QuickLook preview files inside the *.workflow bundle 12 | **/*.workflow/Contents/QuickLook/ 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | 2 | # Do not publish tests to the npm registry. 3 | test/ 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Versioning complies with [semantic versioning (semver)](http://semver.org/). 4 | 5 | 6 | 7 | * **[v0.3.4](https://github.com/mklement0/voices/compare/v0.3.3...v0.3.4)** (2018-03-21): 8 | * [doc] Links to workflow ZIPs fixed. 9 | 10 | * **[v0.3.3](https://github.com/mklement0/voices/compare/v0.3.2...v0.3.3)** (2018-03-08): 11 | * [fix] It is now ensured that the system versions of standard utilities such 12 | as `awk` are called, to prevent unexpected behavior stemming from 13 | user-installed versions in /usr/local/bin getting called. 14 | 15 | * **[v0.3.2](https://github.com/mklement0/voices/compare/v0.3.1...v0.3.2)** (2017-01-03): 16 | * [doc] Limitations of support for third-party voices noted. 17 | * [fix] `voices -m` now works on macOS Sierra. 18 | 19 | * **[v0.3.1](https://github.com/mklement0/voices/compare/v0.3.0...v0.3.1)** (2015-11-03): 20 | * [doc] Added link to Alfred 2 workflow _speak.waf_ as a superior alternative 21 | to the OSX services. 22 | 23 | * **[v0.3.0](https://github.com/mklement0/voices/compare/v0.2.3...v0.3.0)** (2015-10-27): 24 | * [Potentially breaking change] `-i` for reporting voice internals now reports 25 | an extra variable `BundleID` as the last item, i.e., the voice's bundle ID. 26 | 27 | * **[v0.2.3](https://github.com/mklement0/voices/compare/v0.2.2...v0.2.3)** (2015-09-20): 28 | * [doc] `voices` now has a man page (if manually installed, use `voices --man`); 29 | `voices -h` now just prints concise usage information. 30 | 31 | * **[v0.2.2](https://github.com/mklement0/voices/compare/v0.2.1...v0.2.2)** (2015-09-15): 32 | * [dev] Makefile improvements; various other behind-the-scenes tweaks. 33 | 34 | * **[v0.2.1](https://github.com/mklement0/voices/compare/v0.2.0...v0.2.1)** (2015-07-30): 35 | * [doc] Read-me corrections. 36 | 37 | * **[v0.2.0](https://github.com/mklement0/voices/compare/v0.1.9...v0.2.0)** (2015-07-29): 38 | * [enhancement] `voices` now honors custom speaking rates when requested to speak with the `-k` option 39 | * [enhancement] OSX Service `Switch Default Voice.workflow` is now configuration file-based and supports more than 2 voices for cyclical switching; default confirmation text spoken on switching is now the localized name of the new voice's language. 40 | * [new] OSX Service `Speak With Specific Voice.workflow` allows speaking selected text with a fixed alternate voice. 41 | 42 | * **[v0.1.9](https://github.com/mklement0/voices/compare/v0.1.8...v0.1.9)** (2015-07-28): 43 | * [doc] Corrected the mistaken claim that changing the default voice also changes the _VoiceOver_ default voice: the VoiceOver feature has its own default voice, separate from the TTS feature; this utility only changes the _TTS_ default voice, not also the VoiceOver one. 44 | 45 | * **[v0.1.8](https://github.com/mklement0/voices/compare/v0.1.7...v0.1.8)** (2015-07-28): 46 | * [fix] Regression: default customization data for the OSX service reset to original values (two US English-voices). 47 | 48 | * **[v0.1.7](https://github.com/mklement0/voices/compare/v0.1.6...v0.1.7)** (2015-07-28): 49 | * [fix] When switching default voices, any custom speaking rate (words per minute) configured for a given voice via System Preferences is now honored. Note, however, that speaking text with `voices`' own `-k` option does _not_ honor custom speaking rates due to a limitation in the underlying `say` utility. 50 | 51 | * **[v0.1.6](https://github.com/mklement0/voices/compare/v0.1.5...v0.1.6)** (2015-07-28): 52 | * [dev] Pre-commit hook fixed to ensure that the modified workflow and ZIP file are added to the index before committing. 53 | 54 | * **[v0.1.5](https://github.com/mklement0/voices/compare/v0.1.4...v0.1.5)** (2015-07-27): 55 | * [doc] Read-me and CLI help amended with respect to supported OSX versions. 56 | 57 | * **[v0.1.4](https://github.com/mklement0/voices/compare/v0.1.3...v0.1.4)** (2015-07-27): 58 | * [enhancement] Added Automator-based OSX service for switching between two default voices. 59 | * [fix] Inability to determine the default voice on a pristine system is now handled more gracefully. 60 | 61 | * **[v0.1.3](https://github.com/mklement0/voices/compare/v0.1.2...v0.1.3)** (2015-07-06): 62 | * [doc] CLI-help copyediting; wording of `--version` streamlined. 63 | 64 | * **[v0.1.2](https://github.com/mklement0/voices/compare/v0.1.1...v0.1.2)** (2015-07-01): 65 | * [doc] CLI help improved. 66 | 67 | * **[v0.1.1](https://github.com/mklement0/voices/compare/v0.1.0...v0.1.1)** (2015-06-30): 68 | * [doc] Read-me improvements. 69 | 70 | * **v0.1.0** (2015-06-29): 71 | * Initial release. 72 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2018 Michael Klement (http://same2u.net), released under the [MIT license](https://spdx.org/licenses/MIT#licenseText). 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Since we rely on paths relative to the Makefile location, abort if make isn't being run from there. 2 | $(if $(findstring /,$(MAKEFILE_LIST)),$(error Please only invoke this makefile from the directory it resides in)) 3 | # Run all shell commands with bash. 4 | SHELL := bash 5 | # Add the local npm packages' bin folder to the PATH, so that `make` can find them even when invoked directly (not via npm). 6 | # !! Note that this extended path only takes effect in (a) recipe commands that are (b) true shell commands (not optimized away) - when in doubt, simply append ';' 7 | # !! To also use the extended path in $(shell ...) function calls, use $(shell PATH="$(PATH)" ...), 8 | # !! ALSO: WE use "./" rather than "$(PWD)/" to add the path, because if you use PowerShell Core as your shell, $PWD will not be defined as an *environment* variable. 9 | # !! "./" (appending a *relative* path) is safe in this case, because we've ensured above that we're running from the project directory. 10 | export PATH := ./node_modules/.bin:$(PATH) 11 | # Sanity check: git repo must exist. 12 | $(if $(shell [[ -d .git ]] && echo ok),,$(error No git repo found in current dir. Please at least initialize one with 'git init')) 13 | # Sanity check: make sure dev dependencies (and npm) are installed - skip this check only for certain generic targets (':' is the pseudo target used by the `list` target's recipe.) 14 | $(if $(or $(shell [[ '$(MAKECMDGOALS)' =~ list|: ]] && echo ok), $(shell [[ -d ./node_modules/semver ]] && echo 'ok')),,$(error Did you forget to run `npm install` after cloning the repo (Node.js must be installed)? At least one of the required dev dependencies not found)) 15 | # Determine the editor to use for modal editing. Use the same as for git, if configured; otherwise $EDITOR, then fall back to vi (which may be vim). 16 | EDITOR := $(shell git config --global --get core.editor || echo "$${EDITOR:-vi}") 17 | 18 | # Default target (by virtue of being the first non '.'-prefixed target in the file). 19 | .PHONY: _no-target-specified 20 | _no-target-specified: 21 | $(error Please specify the target to make - `make list` shows targets. Alternatively, use `npm test` to run the default tests; `npm run` shows all commands) 22 | 23 | # Lists all targets defined in this makefile. 24 | .PHONY: list 25 | list: 26 | @$(MAKE) -pRrn -f $(lastword $(MAKEFILE_LIST)) : 2>/dev/null | awk -v RS= -F: '/^# File/,/^# Finished Make data base/ {if ($$1 !~ "^[#.]") {print $$1}}' | grep -Ev -e '^[^[:alnum:]]' -e '^$@$$' | sort 27 | 28 | # Open this package's online repository URL (typically, on GitHub) in the default browser. 29 | # Note: Supported on OSX and Freedesktop-compliant systems, which includes many Linux and BSD variants. 30 | .PHONY: browse 31 | browse: 32 | @exe=; url=`json -f package.json repository.url` || exit; \ 33 | [[ `uname` == 'Darwin' ]] && exe='open'; \ 34 | [[ -n `command -v xdg-open` ]] && exe='xdg-open'; \ 35 | [[ -n $$exe ]] || { echo "Don't know how to open $$url in the default browser on this platform." >&2; exit 1; }; \ 36 | "$$exe" "$$url" 37 | 38 | # Open this package's page in the npm registry. 39 | # Note: Supported on OSX and Freedesktop-compliant systems, which includes many Linux and BSD variants. 40 | .PHONY: browse-npm 41 | browse-npm: 42 | @exe=; [[ `json -f package.json private` == 'true' ]] && { echo "This package is marked private (not for publication in the npm registry)." >&2; exit 1; }; \ 43 | url="https://www.npmjs.com/package/`json -f package.json name`" || exit; \ 44 | [[ `uname` == 'Darwin' ]] && exe='open'; \ 45 | [[ -n `command -v xdg-open` ]] && exe='xdg-open'; \ 46 | [[ -n $$exe ]] || { echo "Don't know how to open $$url in the default browser on this platform." >&2; exit 1; }; \ 47 | "$$exe" "$$url" 48 | 49 | .PHONY: test 50 | # To optionally skip tests in the context of target 'release', for instance, invoke with NOTEST=1; e.g.: make release NOTEST=1 51 | test: 52 | ifeq ($(NOTEST),1) 53 | @echo Note: Skipping tests, as requested. >&2 54 | else 55 | @exists() { [ -e "$$1" ]; }; exists ./test/* || { echo "(No tests defined.)" >&2; exit 0; }; \ 56 | if [[ -n $$(json -f package.json main) ]]; then tap ./test; else urchin ./test; fi 57 | endif 58 | 59 | # Commits (with prompt for message) and pushes to the branch of the same name in remote repo 'origin', 60 | # but *without* tags, so as to allow quick pushing of changes without running into problems with tag redefinitions. 61 | # (Tags are only pushed - forcefully - with `make release`.) 62 | .PHONY: push 63 | push: _need-clean-ws-or-no-untracked-files 64 | @[[ -z $$(git status --porcelain || echo no) ]] && echo "-- (Nothing to commit.)" || { git commit || exit; echo "-- Committed."; }; \ 65 | targetBranch=`git symbolic-ref --short HEAD` || exit; \ 66 | git push origin "$$targetBranch" || exit; \ 67 | echo "-- Pushed." 68 | 69 | 70 | # Reports the current version number - both from package.json and as defined by the latest git tag 71 | # Implementation note: simply uses 'version' as a prerequisite, which queries $(MAKECMDGOALS) to adjust its behavior based on the caller. 72 | .PHONY: verinfo 73 | verinfo: version 74 | 75 | # Increments the package's version number: 76 | # Unless called via 'make verinfo', the workspace must be clean or at least have no untracked files. 77 | # If VER is *not* specified in the environment: 78 | # Reports the current version number - both from package.json and as defined by the latest git tag. 79 | # If 'make version' was called directly, then prompts to change the version number. 80 | # If called via 'make release', only prompts to change the version number if the git tag version number is the same as the package's. 81 | # VER is set to the value entered and processing continues below. 82 | # If VER *is* specified or continuing from above: 83 | # Validates the new version number: 84 | # If an increment specifier was given, increments from the latest package.json version number (as the version numbers stored in source files are assumed to be in sync with package.json). 85 | # Implementation note: semver, as of v4.3.6, does not validate increment specifiers and simply defaults to 'patch' in case of an valid specifier; thus, we roll our own validation here. 86 | # An increment specifier starting with 'pre' increments [to] a prerelease version number. By default, this simply appends or increments '-', whereas '--preid ' can be used 87 | # to append '-' instead; however, we don't expose that, at least for now, though the user may specify an explicit, full pre-release version number. 88 | # We use tag 'pre' with npm publish --tag, so as to have the latest prerelease be installable with @pre, analogous to the (implicit) 'latest' tag that tracks production releases. 89 | # An explicitly specified version number must be *higher* than the current one; pass variable FORCE=1 to override this in exceptional situations. 90 | # Updates the version number in package.json and in source files in ./bin and ./lib. 91 | .PHONY: version 92 | version: 93 | @[[ '$(MAKECMDGOALS)' == *verinfo* ]] && infoOnly=1 || infoOnly=0; \ 94 | gitTagVer=`git describe --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null || echo '(none)'` || exit; gitTagVer=$${gitTagVer#v}; \ 95 | pkgVer=`json -f package.json version` || exit; \ 96 | if [[ -z $$VER ]]; then \ 97 | printf 'CURRENT version:\n\t%s (package.json)\n\t%s (git tag)\n' "$$pkgVer" "$$gitTagVer"; \ 98 | (( infoOnly )) && exit; \ 99 | [[ $$pkgVer != "$$gitTagVer" && $$pkgVer != '0.0.0' ]] && { alreadyBumped=1 || alreadyBumped=0; }; \ 100 | if [[ '$(MAKECMDGOALS)' == 'release' && $$alreadyBumped -eq 1 ]]; then \ 101 | printf "=== `[[ $$pkgVer == *-* ]] && printf 'PRE-'`RELEASING:\n\t%s -> **%s** \n===\n" "$$gitTagVer" "$$pkgVer"; \ 102 | read -p '(Y)es or (c)hange (y/c/N)?: ' -re response && [[ "$$response" == [yYcC] ]] || { echo 'Aborted.' >&2; exit 2; }; \ 103 | [[ $$response =~ [yY] ]] && exit 0; \ 104 | alreadyBumped=0; \ 105 | fi; \ 106 | if [[ '$(MAKECMDGOALS)' == 'version' || $$alreadyBumped -eq 0 ]]; then \ 107 | echo "==="; \ 108 | echo "Enter new version number in full or as one of: 'patch', 'minor', 'major', optionally prefixed with 'pre', or 'prerelease'."; \ 109 | echo "(Alternatively, pass a value from the command line with 'VER='.)"; \ 110 | read -p "NEW VERSION number (just Enter to abort)?: " -re VER && { [[ -z $$VER ]] && echo 'Aborted.' >&2 && exit 2; }; \ 111 | fi; \ 112 | fi; \ 113 | oldVer=$$pkgVer; \ 114 | newVer=$${VER#v}; \ 115 | if printf "$$newVer" | grep -q '^[0-9]'; then \ 116 | semver "$$newVer" >/dev/null || { echo "Invalid semver version number specified: $$VER" >&2; exit 2; }; \ 117 | [[ "$(FORCE)" != '1' ]] && { semver -r "> $$oldVer" "$$newVer" >/dev/null || { echo "Invalid version number specified: $$VER - must be HIGHER than $$oldVer. To force this change, use FORCE=1 on the command line." >&2; exit 2; }; } \ 118 | else \ 119 | [[ $$newVer =~ ^(patch|minor|major|prepatch|preminor|premajor|prerelease)$$ ]] && newVer=`semver -i "$$newVer" "$$oldVer"` || { echo "Invalid version-increment specifier: $$VER" >&2; exit 2; } \ 120 | fi; \ 121 | printf "=== About to BUMP VERSION:\n\t$$oldVer -> **$$newVer**\n===\nProceed (y/N)?: " && read -re response && [[ "$$response" = [yY] ]] || { echo 'Aborted.' >&2; exit 2; }; \ 122 | for dir in ./bin ./lib; do [[ -d $$dir ]] && { replace --quiet --recursive "v$${oldVer//./\\.}" "v$${newVer}" "$$dir" || exit; }; done; \ 123 | [[ `json -f package.json version` == "$$newVer" ]] || { npm version $$newVer --no-git-tag-version >/dev/null && printf $$'\e[0;33m%s\e[0m\n' 'package.json' || exit; }; \ 124 | [[ $$gitTagVer == '(none)' ]] && newVerMdSnippet="**v$$newVer**" || newVerMdSnippet="**[v$$newVer](`json -f package.json repository.url | sed 's/.git$$//'`/compare/v$$gitTagVer...v$$newVer)**"; \ 125 | grep -Eq "\bv$${newVer//./\.}[^[:digit:]-]" CHANGELOG.md || { { sed -n '1,/^\n\n' > doc/"$$cliName".md; \ 201 | "$$cliPath" --man-source >> doc/"$$cliName".md || { printf "ERROR: Failed to extract man-page source.\nPlease ensure that '$$cliName --man-source' outputs the Markdown-formatted man-page source.\n" | fold -s >&2; exit 1; }; \ 202 | "$$cliPath" --man-source | marked-man --version "$$ver" > man/"$$cliName".1 || { echo "Do you need to install marked-man (npm install marked-man --save-dev)?" | fold -s >&2; exit 1; }; \ 203 | [[ '$(MAKECMDGOALS)' == 'update-man' ]] && echo "-- 'doc/$$cliName.md' and 'man/$$cliName.1' updated."$$'\n'"To view the latter as a man page, run: man man/$$cliName.1"$$'\n'"To update and view in one step, run: make view-man" || : 204 | 205 | # If man-page creation is turned on: recreate the man page and view it with `man`. 206 | .PHONY: view-man 207 | view-man: update-man 208 | @manfile=`json -f package.json man`; [[ -n $$manfile ]] || { echo "ERROR: No 'man' property found in 'package.json'." >&2; exit 2; }; \ 209 | man "$$manfile" 210 | 211 | # Toggles inclusion of an auto-updating TOC in README.md via doctoc. 212 | .PHONY: toggle-toc 213 | toggle-toc: 214 | @isOn=$$([[ `json -f package.json net_same2u.make_pkg.tocOn` == 'true' ]] && printf 1 || printf 0); \ 215 | nowState=`(( isOn )) && printf 'ON' || printf 'OFF'`; otherState=`(( isOn )) && printf 'OFF' || printf 'ON'`; \ 216 | echo "Inclusion of an auto-updating TOC for README.md is currently $$nowState."; \ 217 | read -re -p "Turn it $$otherState (y/N)?: " response && [[ "$$response" =~ [yY] ]] || { exit 0; }; \ 218 | json -I -f package.json -e 'this.net_same2u || (this.net_same2u = {}); this.net_same2u.make_pkg || (this.net_same2u.make_pkg = {}); this.net_same2u.make_pkg.tocOn = '`(( isOn )) && printf 'false' || printf 'true'`'; this.net_same2u.make_pkg.tocTitle || (this.net_same2u.make_pkg.tocTitle = "**Contents**")' || exit; \ 219 | if (( isOn )); then \ 220 | echo "NOTE: To be safe, no attempt was made to remove any existing TOC from README.md, if present." | fold -s >&2; \ 221 | else \ 222 | echo "-- Automatic TOC generation for README.md activated."; \ 223 | printf "Run 'make update-toc' to insert a TOC now.\n'make update-readme' and 'make release' will now update it automatically.\n" | fold -s; \ 224 | fi 225 | 226 | # Toggles generation of a man page via marked-man, based on a Markdown-formatted document 227 | # that the package's CLI must output with --man-source. 228 | .PHONY: toggle-man 229 | toggle-man: 230 | @isOn=$$([[ `json -f package.json net_same2u.make_pkg.manOn` == 'true' ]] && printf 1 || printf 0); \ 231 | nowState=`(( isOn )) && printf 'ON' || printf 'OFF'`; otherState=`(( isOn )) && printf 'OFF' || printf 'ON'`; \ 232 | echo "Generating a man page for this package's CLI is currently $$nowState."; \ 233 | read -re -p "Turn it $$otherState (y/N)?: " response && [[ "$$response" =~ [yY] ]] || { exit 0; }; \ 234 | if (( ! isOn )); then \ 235 | read -r cliName cliPath < <(json -f package.json bin | json -Ma key value | head -n 1); \ 236 | [[ -n $$cliName ]] || { echo "ERROR: No CLI declared in 'package.json'; please declare a CLI via the 'bin' property and try again." | fold -s >&2; exit 1; }; \ 237 | fi; \ 238 | json -I -f package.json -e 'this.net_same2u || (this.net_same2u = {}); this.net_same2u.make_pkg || (this.net_same2u.make_pkg = {}); this.net_same2u.make_pkg.manOn = '`(( isOn )) && printf 'false' || printf 'true'` || exit; \ 239 | if (( isOn )); then \ 240 | echo "-- Man-page creation is now OFF."; \ 241 | echo "NOTE: To be safe, a 'man' property, if present, was not removed from 'package.json', and no attempt was made to uninstall the 'marked-man' package, if present. Please make required changes manually." | fold -s >&2; \ 242 | else \ 243 | [[ -n `json -f package.json devDependencies.marked-man` ]] || { echo "-- Installing marked-man as a dev. dependency..."; npm install --save-dev marked-man || exit; }; \ 244 | [[ -n `json -f package.json man` ]] && { echo "NOTE: Retaining existing 'man' property in 'package.json'." >&2; } || \ 245 | { json -I -f package.json -e "this.man = \"./man/$$cliName.1\"" || exit; }; \ 246 | echo "-- Man-page creation is now ON."; echo "Run 'make update-man' to generate the man page now."$$'\n'"Note that '$$cliName --man-source' must output the man-page source in Markdown format for this to work." | fold -s; \ 247 | fi 248 | 249 | 250 | # Updates LICENSE.md if the stated calendar year (e.g., '2015') / the end point in a calendar-year range (e.g., '2014-2015') 251 | # lies in the past; E.g., if the current calendary year is 2016, the first example is updated to '2015-2016', and the second 252 | # one to '2014-2016'. 253 | .PHONY: update-license-year 254 | update-license-year: 255 | @f='LICENSE.md'; thisYear=`date +%Y`; yearRange=`sed -n 's/.*(c) \([0-9]\{4\}\)\(-[0-9]\{4\}\)\{0,1\}.*/\1\2/p' "$$f"`; \ 256 | [[ -n $$yearRange ]] || { echo "Failed to extract calendar year(s) from '$$f'." >&2; exit 1; }; laterYear=$${yearRange#*-}; \ 257 | if (( laterYear < thisYear )); then \ 258 | replace -s '(\(c\) )([0-9]{4})(-[0-9]{4})?' '$$1$$2-'"$$thisYear" "$$f" || exit; \ 259 | echo "NOTE: '$$f' updated to reflect current calendar year, $$thisYear."; \ 260 | elif [[ '$(MAKECMDGOALS)' == 'update-license-year' ]]; then \ 261 | echo "('$$f' calendar year(s) are up-to-date: $$yearRange)"; \ 262 | fi 263 | 264 | # --------- Aux. targets 265 | 266 | # If applicable, replaces the usage read-me chapter with the current CLI help output, 267 | # enclosed in a fenced codeblock and preceded by '$ --help'. 268 | # Replacement is attempted if the project at hand has a (at least one) CLI, as defined in the 'bin' key in package.json. 269 | # is an *object* that has (at least 1) property (rather than containing a string-scalar value that implies the package name as the CLI name). 270 | # - If 'bin' has *multiple* properties, the *1st* is the one whose usage info is to be used. 271 | # To change this, modify CLI_HELP_CMD in the shell command below. 272 | .PHONY: _update-readme-usage 273 | # The arguments to pass to the CLI to have it output its help. 274 | CLI_HELP_ARGS:= --help 275 | # Note that the recipe exits right away if no CLIs are found in 'package.json'. 276 | # TO DISABLE THIS RULE, REMOVE ALL OF ITS RECIPE LINES. 277 | _update-readme-usage: 278 | @read -r cliName cliPath < <(json -f package.json bin | json -Ma key value | head -n 1) || exit 0; \ 279 | CLI_HELP_CMD=( "$$cliPath" $(CLI_HELP_ARGS) ); \ 280 | CLI_HELP_CMD_DISPLAY=( "$${CLI_HELP_CMD[@]}" ); CLI_HELP_CMD_DISPLAY[0]="$$cliName"; \ 281 | newText="$${CLI_HELP_CMD_DISPLAY[@]}"$$'\n\n'"$$( "$${CLI_HELP_CMD[@]}" )" || { echo "Failed to update read-me chapter: usage: invoking CLI help failed: $${CLI_HELP_CMD[@]}" >&2; exit 1; }; \ 282 | newText="$${newText//\$$/$$\$$}"; \ 283 | newText="$${newText//~/\~}"; \ 284 | replace --count --quiet --multiline=false '(\n)(\n\s*?\n```nohighlight\n\$$ )[\s\S]*?(\n```\n|$$)' '$$1$$2'"$$newText"'$$3' README.md | grep -Fq ' (1)' || { echo "Failed to update read-me chapter: usage." >&2; exit 1; } 285 | # !! REGRETTABLY, the ``` sequences in the line above break syntax coloring for the rest of the file in Sublime Text 3 - ?? unclear, how to work around that. 286 | 287 | # - Replaces the '## License' chapter with the contents of LICENSE.md 288 | .PHONY: _update-readme-license 289 | # TO DISABLE THIS RULE, REMOVE ALL OF ITS RECIPE LINES. 290 | _update-readme-license: 291 | @newText=$$'\n'"$$(< LICENSE.md)"$$'\n'; \ 292 | newText="$${newText//\$$/$$\$$}"; \ 293 | replace --count --quiet --multiline=false '(^|\n)(#+ License\n)[\s\S]*?(\n([ \t]*\s*?\n)?#|$$)' '$$1$$2'"$$newText"'$$3' README.md | grep -Fq ' (1)' || { echo "Failed to update read-me chapter: license." >&2; exit 1; } 294 | 295 | # - Replaces the dependencies chapter with the current list of dependencies. 296 | .PHONY: _update-readme-dependencies 297 | # A regex that matches the chapter heading to replace in README.md; watch for unintentional trailing whitespace. '#' must be represented as '\#'. 298 | README_HEADING_DEPENDENCIES := \#+ npm dependencies 299 | # TO DISABLE THIS RULE, REMOVE ALL OF ITS RECIPE LINES. 300 | _update-readme-dependencies: 301 | @newText=$$'\n'$$( \ 302 | keys=( dependencies peerDependencies devDependencies optionalDependencies ); \ 303 | qualifiers=( '' '(P)' '(D)' '(O)'); \ 304 | i=0; \ 305 | for key in "$${keys[@]}"; do \ 306 | json -f ./package.json $$key | json -ka | { \ 307 | while read -r pn; do \ 308 | hp=$$(json -f "./node_modules/$$pn/package.json" homepage); \ 309 | echo "* [$$pn$${qualifiers[i]:+ $${qualifiers[i]}}]($$hp)"; \ 310 | done \ 311 | }; \ 312 | (( ++i )); \ 313 | done)$$'\n'; \ 314 | [[ -n $$newText ]] || { echo "Failed to determine npm dependencies." >&2; exit 1; }; \ 315 | newText="$${newText//\$$/$$\$$}"; \ 316 | replace --count --quiet --multiline=false '(^|\n)($(README_HEADING_DEPENDENCIES)\n)[\s\S]*?(\n([ \t]*\s*?\n)?#|$$)' '$$1$$2'"$$newText"'$$3' README.md | grep -Fq ' (1)' || { echo "Failed to update read-me chapter: npm dependencies." >&2; exit 1; } 317 | 318 | # - Replaces the changelog chapter with the contents of CHANGELOG.md 319 | .PHONY: _update-readme-changelog 320 | # A regex that matches the chapter heading to replace in README.md; watch for unintentional trailing whitespace. '#' must be represented as '\#'. 321 | README_HEADING_CHANGELOG := \#+ Changelog 322 | # TO DISABLE THIS RULE, REMOVE ALL OF ITS RECIPE LINES. 323 | _update-readme-changelog: 324 | @newText=$$'\n'"$$(tail -n +3 CHANGELOG.md)"$$'\n'; \ 325 | newText="$${newText//\$$/$$\$$}"; \ 326 | replace --count --quiet --multiline=false '(^|\n)($(README_HEADING_CHANGELOG)\n)[\s\S]*?(\n([ \t]*\s*?\n)?#|$$)' '$$1$$2'"$$newText"'$$3' README.md | grep -Fq ' (1)' || { echo "Failed to update read-me chapter: changelog." >&2; exit 1; } 327 | 328 | .PHONY: _need-master-branch 329 | _need-master-branch: 330 | @[[ `git symbolic-ref --short HEAD` == 'master' ]] || { echo 'Please release from the master branch only.' >&2; exit 2; } 331 | 332 | # Ensures that the git workspace is clean or contains no untracked files - any tracked files are implicitly added to the index. 333 | .PHONY: _need-clean-ws-or-no-untracked-files 334 | _need-clean-ws-or-no-untracked-files: 335 | @git add --update . || exit 336 | @[[ -z $$(git status --porcelain | awk -F'\0' '$$2 != " " { print $$2 }') ]] || { echo "Workspace must either be clean or contain no untracked files; please add untracked files to the index first (e.g., \`git add .\`) or delete them." >&2; exit 2; } 337 | 338 | # Ensure that a remote git repo named 'origin' is defined. 339 | .PHONY: _need-origin 340 | _need-origin: 341 | @git remote | grep -Fqx 'origin' || { echo "ERROR: Remote git repo 'origin' must be defined." >&2; exit 2; } 342 | 343 | # Unless the package is marked private, ensure that npm credentials have been saved. 344 | .PHONY: _need-npm-credentials 345 | _need-npm-credentials: 346 | @[[ `json -f package.json private` == 'true' ]] && exit 0; \ 347 | grep -Eq '^//registry.npmjs.org/:(_password|_authToken)=' ~/.npmrc || { echo "ERROR: npm-registry credentials not found. Please log in with 'npm login' in order to enable publishing." >&2; exit 2; }; \ 348 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://img.shields.io/npm/v/voices.svg)](https://npmjs.com/package/voices) [![license](https://img.shields.io/npm/l/voices.svg)](https://github.com/mklement0/voices/blob/master/LICENSE.md) 2 | 3 | 4 | 5 | 6 | **Contents** 7 | 8 | - [voices — TTS CLI for macOS](#voices-&mdash-tts-cli-for-macos) 9 | - [Examples](#examples) 10 | - [Installation](#installation) 11 | - [Installation from the npm registry](#installation-from-the-npm-registry) 12 | - [Manual installation](#manual-installation) 13 | - [Usage](#usage) 14 | - [macOS Service for switching between default voices](#macos-service-for-switching-between-default-voices) 15 | - [Installation](#installation-1) 16 | - [Customization](#customization) 17 | - [macOS Service for speaking selected text with a specific voice](#macos-service-for-speaking-selected-text-with-a-specific-voice) 18 | - [Installation](#installation-2) 19 | - [Customization](#customization-1) 20 | - [License](#license) 21 | - [Acknowledgements](#acknowledgements) 22 | - [npm dependencies](#npm-dependencies) 23 | - [Changelog](#changelog) 24 | 25 | 26 | 27 | # voices — TTS CLI for macOS 28 | 29 | [**IMPORTANT**: PARTIALLY BROKEN AS OF macOS Sonoma (14): many installed voices aren't recognized, and the default voice isn't listed with `-l`] 30 | 31 | `voices` is a **macOS CLI** for **changing the default TTS (text-to-speech) voice** and for **printing information about and/or speaking text with multiple voices**. 32 | 33 | `voices` complements the standard `say` utility by: 34 | 35 | * providing a way to change the default voice 36 | * operating on _active_ voices - those selected for active use - rather than all _installed_ voices 37 | * filtering voices by language 38 | * speaking text with _multiple_ voices. 39 | * On a related note: For a simple `say` wrapper that supports text with _embedded instructions to change the voice mid-text_ (e.g., 40 | `[[voice alex]]`), assuming you have [Powershell](https://github.com/PowerShell/PowerShell#get-powershell) installed, see [this comment](https://github.com/mklement0/voices/issues/6#issuecomment-666714554). 41 | 42 | **Caveats**: 43 | 44 | * As of macOS 10.12 (Sierra), there is no documented programmatic way to change the default voice. Thus, this utility makes use of undocumented system internals, which, unfortunately, means that future compatibility of this feature is uncertain. [Feedback](https://github.com/mklement0/voices/issues) welcome. 45 | 46 | * `voices` currently only fully supports voices provided by _Apple_. Support 47 | for third-party voices such as [InfoVox iVox](http://www.assistiveware.com/product/infovox-ivox) is limited to _speaking_ with them, 48 | and *the macOS Services documented below will not work with them*. 49 | 50 | * Additionally, as of macOS 10.15, _Siri_ voices are _not_ supported, due to lack of API support (see [this Stack Overflow question](https://stackoverflow.com/q/61122378/45375)). 51 | 52 | See the examples below, concise [usage information](#usage) further below, 53 | or read the [manual](doc/voices.md). 54 | 55 | Additionally, two **macOS Services** are offered: 56 | 57 | * a **service for switching between two or more default voices** - see [below](#macos-service-for-switching-between-default-voices). 58 | * a **service for speaking selected text with a specific voice** - see [below](#macos-service-for-speaking-selected-text-with-a-specific-voice) 59 | 60 | Note: If you have [Alfred](http://alfredapp.com/) with its [Power Pack](https://www.alfredapp.com/powerpack/), consider workflow 61 | **[speak.awf](https://github.com/mklement0/speak.awf)** as a superior alternative. 62 | 63 | # Examples 64 | 65 | ```shell 66 | # List all active voices; add -a to list all installed ones. 67 | voices -l 68 | 69 | # Print information about the default voice and speak its demo text. 70 | voices -d -k 71 | 72 | # Print information about voice 'Alex'. 73 | voices alex 74 | 75 | # Make 'Alex' the new default voice, print information about it, and 76 | # speak text that announces the change. 77 | voices -k'The new default voice is Alex.' -d alex 78 | 79 | # List languages for which at least one voice is active. 80 | voices -L 81 | 82 | # List active French voices. 83 | voices -l fr 84 | 85 | # Print information about all active voices and speak 86 | # their respective demo text. 87 | voices -l -k 88 | 89 | # Print information about all active Spanish voices and speak their 90 | # respective demo text. 91 | voices -k -l es 92 | 93 | # Say "hello", first with voice Alex, then with Jill, suppressing printed 94 | # output. 95 | voices -k"hello" -q alex jill 96 | ``` 97 | 98 | # Installation 99 | 100 | **Supported platforms** 101 | 102 | * **macOS** 103 | 104 | Verified to work from OS X 10.8 (Mountain Lion) up to macOS 10.12 (Sierra). 105 | 106 | The change-the-default-voice feature makes use of undocumented system internals, so its future compatiblity is uncertain. 107 | [Do let me know](https://github.com/mklement0/voices/issues) if you find the feature broken in a future macOS version. 108 | 109 | ## Installation from the npm registry 110 | 111 | Note: Even if you don't use Node.js, its package manager, `npm`, works across platforms and is easy to install; try [`curl -L http://git.io/n-install | bash`](https://github.com/mklement0/n-install) 112 | 113 | With [Node.js](http://nodejs.org/) installed, install [the package](https://www.npmjs.com/package/voices) as follows: 114 | 115 | [sudo] npm install voices -g 116 | 117 | **Note**: 118 | 119 | * Whether you need `sudo` depends on how you installed Node.js / io.js and whether you've [changed permissions later](https://docs.npmjs.com/getting-started/fixing-npm-permissions); if you get an `EACCES` error, try again with `sudo`. 120 | * The `-g` ensures [_global_ installation](https://docs.npmjs.com/getting-started/installing-npm-packages-globally) and is needed to put `voices` in your system's `$PATH`. 121 | 122 | ## Manual installation 123 | 124 | * Download [the CLI](https://raw.githubusercontent.com/mklement0/voices/stable/bin/voices) as `voices`. 125 | * Make it executable with `chmod +x voices`. 126 | * Move it or symlink it to a folder in your `$PATH`, such as `/usr/local/bin`. 127 | 128 | # Usage 129 | 130 | Find concise usage information below; for complete documentation, read the 131 | [manual online](doc/voices.md), or, once installed, run `man voices` 132 | (`voices --man` if installed manually). 133 | 134 | 135 | 136 | ```nohighlight 137 | $ voices --help 138 | 139 | 140 | Get or set or speak with the DEFAULT VOICE: 141 | 142 | voices [] [-d []] 143 | 144 | LIST INFORMATION about / speak with voices: 145 | 146 | voices [] ... 147 | 148 | List / speak with ALL VOICES, optionally FILTERED BY LANGUAGES: 149 | 150 | voices [] -l [...] 151 | 152 | LIST LANGUAGES among voices: 153 | 154 | voices -L [-a] 155 | 156 | MANAGE VOICES in System Preferences: 157 | 158 | voices -m 159 | 160 | Shared options (synopsis forms 1-3): 161 | 162 | -a target all installed voices (default: only active ones) 163 | -k speak demo text with all targeted voices 164 | -k"" speak specified text 165 | -k- speak text provided via stdin 166 | -b output format: print voice names only 167 | -i output format: print voice internals 168 | -q quiet mode: no printed output 169 | 170 | Standard options: --help, --man, --version, --home 171 | ``` 172 | 173 | # macOS Service for switching between default voices 174 | 175 | This service, which uses an embedded copy of `voices`, is helpful if you use text-to-speech in two or more languages and want to quickly 176 | switch the default voice between multiple designated voices cyclically, in combination with the built-in speak-selected-text service. 177 | 178 | Every time the service is invoked, the next designated voice is made the default voice, and the localized name of the new voice's 179 | language is spoken to confirm the change (this is configurable). 180 | 181 | You can invoke the service from any application's standard `Services` menu, category `General`, or assign it a keyboard shortcut via 182 | `System Preferences > Keyboard > Shortcuts > Services`. 183 | 184 | ## Installation 185 | 186 | * Download [this ZIP file](https://raw.githubusercontent.com/mklement0/voices/stable/osx-service/Switch%20Default%20Voice.workflow.zip). 187 | * In Finder, open the ZIP file, which creates package `Switch Default Voice.workflow` in the same folder. 188 | * Open `Switch Default Voice.workflow` and choose `Install` when prompted - this will place the package in `~/Library/Services/`. 189 | * Choose `Done` when prompted and proceed with customization below. 190 | 191 | ## Customization 192 | 193 | * Invoke the service for the first time to prompt creating and editing the configuration file. 194 | * From any application, open that application's menu and select `Services > Switch Default Voice`. 195 | * You will be prompted to edit the configuration file, where you can specify the voices to switch between; follow the instructions in the file. 196 | * To assign a keyboard shortcut to the service, go to `System Preferences > Keyboard > Shortcuts`, category `Services`, 197 | scroll to sub-category `General` in the list on the right, select `Switch Default Voice`, and click just inside the right edge of the list item. 198 | * To customize the service again later, open `~/.SwitchDefaultVoice-rc` in your text editor. 199 | 200 | # macOS Service for speaking selected text with a specific voice 201 | 202 | This service provides an alternative to switching the default voice: it speaks 203 | selected text in the frontmost application with a fixed alternate voice, which 204 | allows it to be used _alongside_ the built-in speak-selected-text service, which 205 | always uses the _default_ voice (see `System Preferences > Dictation & Speech > Text to Speech`). 206 | 207 | Typically, you would use this service to speak selected text with a voice 208 | that speaks a _different language_. 209 | 210 | You can invoke it from the standard `Services` menu, category `Text`, whenever text is selected in the frontmost application, or assign it a 211 | keyboard shortcut via `System Preferences > Keyboard > Shortcuts > Services`; e.g., `` ⌥` `` (Opt-\`) to parallel the default shortcut for 212 | the built-in service, `⌥⎋` (Opt-Esc). 213 | 214 | Invoking the service again while text from a previous invocation is still being spoken aborts speaking. 215 | _Caveat_: This only works if text - _any_ text - is selected in the activate applciation at the time the service is invoked again. 216 | 217 | If desired, you can duplicate the service so as to be able to speak with one of _multiple_ alternate voices: 218 | Once installed, duplicate `~/Library/Services/Speak With Specific Voice.workflow` in Finder, give it a meaningful name, 219 | and customize the duplicate as described below. 220 | 221 | ## Installation 222 | 223 | * Download [this ZIP file](https://raw.githubusercontent.com/mklement0/voices/stable/osx-service/Speak%20With%20Specific%20Voice.workflow.zip). 224 | * In Finder, open the ZIP file, which creates package `Speak With Specific Voice.workflow` in the same folder. 225 | * Open `Speak With Specific Voice.workflow` and choose `Install` when prompted - this will place the package in `~/Library/Services/`. 226 | * Choose `Open in Automator` when prompted and proceed with customization below. 227 | 228 | ## Customization 229 | 230 | * In Automator, follow the instructions at the top of the document, which currently only require you to specify the name of the desired voice. 231 | * Apply your customizations between the lines `# ------- BEGIN: CUSTOMIZE` and `# ------- END: CUSTOMIZE`. 232 | * To assign a keyboard shortcut, go to `System Preferences > Keyboard > Shortcuts`, category `Services`, scroll to sub-category `General` in the list on the right, select `Speak With Specific Voice.workflow`, and click just inside the right edge of the list item. 233 | * To customize the service again later, open `~/Library/Services/Speak With Specific Voice.workflow` in Automator. 234 | * If you have trouble navigating to `~/Library`, activate Finder, hold down the Option key while selecting the `Go` menu, and select `Library`; from there, navigate to subfolder `Services` and open package `Speak With Specific Voice.workflow`. 235 | 236 | 237 | 238 | # License 239 | 240 | Copyright (c) 2015-2018 Michael Klement (http://same2u.net), released under the [MIT license](https://spdx.org/licenses/MIT#licenseText). 241 | 242 | ## Acknowledgements 243 | 244 | This project gratefully depends on the following open-source components, according to the terms of their respective licenses. 245 | 246 | [npm](https://www.npmjs.com/) dependencies below have optional suffixes denoting the type of dependency; the *absence* of a suffix denotes a required *run-time* dependency: `(D)` denotes a *development-time-only* dependency, `(O)` an *optional* dependency, and `(P)` a *peer* dependency. 247 | 248 | 249 | 250 | ## npm dependencies 251 | 252 | * [doctoc (D)](https://github.com/thlorenz/doctoc) 253 | * [json (D)](https://github.com/trentm/json) 254 | * [marked-man (D)](https://github.com/kapouer/marked-man#readme) 255 | * [replace (D)](https://github.com/harthur/replace) 256 | * [semver (D)](https://github.com/npm/node-semver#readme) 257 | * [tap (D)](https://github.com/isaacs/node-tap) 258 | * [urchin (D)](https://git.sdf.org/tlevine/urchin) 259 | 260 | 261 | 262 | # Changelog 263 | 264 | Versioning complies with [semantic versioning (semver)](http://semver.org/). 265 | 266 | 267 | 268 | * **[v0.3.4](https://github.com/mklement0/voices/compare/v0.3.3...v0.3.4)** (2018-03-21): 269 | * [doc] Links to workflow ZIPs fixed. 270 | 271 | * **[v0.3.3](https://github.com/mklement0/voices/compare/v0.3.2...v0.3.3)** (2018-03-08): 272 | * [fix] It is now ensured that the system versions of standard utilities such 273 | as `awk` are called, to prevent unexpected behavior stemming from 274 | user-installed versions in /usr/local/bin getting called. 275 | 276 | * **[v0.3.2](https://github.com/mklement0/voices/compare/v0.3.1...v0.3.2)** (2017-01-03): 277 | * [doc] Limitations of support for third-party voices noted. 278 | * [fix] `voices -m` now works on macOS Sierra. 279 | 280 | * **[v0.3.1](https://github.com/mklement0/voices/compare/v0.3.0...v0.3.1)** (2015-11-03): 281 | * [doc] Added link to Alfred 2 workflow _speak.waf_ as a superior alternative 282 | to the OSX services. 283 | 284 | * **[v0.3.0](https://github.com/mklement0/voices/compare/v0.2.3...v0.3.0)** (2015-10-27): 285 | * [Potentially breaking change] `-i` for reporting voice internals now reports 286 | an extra variable `BundleID` as the last item, i.e., the voice's bundle ID. 287 | 288 | * **[v0.2.3](https://github.com/mklement0/voices/compare/v0.2.2...v0.2.3)** (2015-09-20): 289 | * [doc] `voices` now has a man page (if manually installed, use `voices --man`); 290 | `voices -h` now just prints concise usage information. 291 | 292 | * **[v0.2.2](https://github.com/mklement0/voices/compare/v0.2.1...v0.2.2)** (2015-09-15): 293 | * [dev] Makefile improvements; various other behind-the-scenes tweaks. 294 | 295 | * **[v0.2.1](https://github.com/mklement0/voices/compare/v0.2.0...v0.2.1)** (2015-07-30): 296 | * [doc] Read-me corrections. 297 | 298 | * **[v0.2.0](https://github.com/mklement0/voices/compare/v0.1.9...v0.2.0)** (2015-07-29): 299 | * [enhancement] `voices` now honors custom speaking rates when requested to speak with the `-k` option 300 | * [enhancement] OSX Service `Switch Default Voice.workflow` is now configuration file-based and supports more than 2 voices for cyclical switching; default confirmation text spoken on switching is now the localized name of the new voice's language. 301 | * [new] OSX Service `Speak With Specific Voice.workflow` allows speaking selected text with a fixed alternate voice. 302 | 303 | * **[v0.1.9](https://github.com/mklement0/voices/compare/v0.1.8...v0.1.9)** (2015-07-28): 304 | * [doc] Corrected the mistaken claim that changing the default voice also changes the _VoiceOver_ default voice: the VoiceOver feature has its own default voice, separate from the TTS feature; this utility only changes the _TTS_ default voice, not also the VoiceOver one. 305 | 306 | * **[v0.1.8](https://github.com/mklement0/voices/compare/v0.1.7...v0.1.8)** (2015-07-28): 307 | * [fix] Regression: default customization data for the OSX service reset to original values (two US English-voices). 308 | 309 | * **[v0.1.7](https://github.com/mklement0/voices/compare/v0.1.6...v0.1.7)** (2015-07-28): 310 | * [fix] When switching default voices, any custom speaking rate (words per minute) configured for a given voice via System Preferences is now honored. Note, however, that speaking text with `voices`' own `-k` option does _not_ honor custom speaking rates due to a limitation in the underlying `say` utility. 311 | 312 | * **[v0.1.6](https://github.com/mklement0/voices/compare/v0.1.5...v0.1.6)** (2015-07-28): 313 | * [dev] Pre-commit hook fixed to ensure that the modified workflow and ZIP file are added to the index before committing. 314 | 315 | * **[v0.1.5](https://github.com/mklement0/voices/compare/v0.1.4...v0.1.5)** (2015-07-27): 316 | * [doc] Read-me and CLI help amended with respect to supported OSX versions. 317 | 318 | * **[v0.1.4](https://github.com/mklement0/voices/compare/v0.1.3...v0.1.4)** (2015-07-27): 319 | * [enhancement] Added Automator-based OSX service for switching between two default voices. 320 | * [fix] Inability to determine the default voice on a pristine system is now handled more gracefully. 321 | 322 | * **[v0.1.3](https://github.com/mklement0/voices/compare/v0.1.2...v0.1.3)** (2015-07-06): 323 | * [doc] CLI-help copyediting; wording of `--version` streamlined. 324 | 325 | * **[v0.1.2](https://github.com/mklement0/voices/compare/v0.1.1...v0.1.2)** (2015-07-01): 326 | * [doc] CLI help improved. 327 | 328 | * **[v0.1.1](https://github.com/mklement0/voices/compare/v0.1.0...v0.1.1)** (2015-06-30): 329 | * [doc] Read-me improvements. 330 | 331 | * **v0.1.0** (2015-06-29): 332 | * Initial release. 333 | -------------------------------------------------------------------------------- /bin/voices: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # --- STANDARD SCRIPT-GLOBAL CONSTANTS 4 | 5 | kTHIS_NAME=${BASH_SOURCE##*/} 6 | kTHIS_HOMEPAGE='https://github.com/mklement0/voices' 7 | kTHIS_VERSION='v0.3.4' # NOTE: This assignment is automatically updated by `make version VER=` - DO keep the 'v' prefix. 8 | 9 | unset CDPATH # Prevent unexpected `cd` behavior. 10 | PATH='/usr/bin:/bin:/usr/sbin:/sbin' # Use default $PATH to ensure that system versions of utilities are called. 11 | 12 | # --- Begin: STANDARD HELPER FUNCTIONS 13 | 14 | die() { echo "$kTHIS_NAME: ERROR: ${1:-"ABORTING due to unexpected error."}" 1>&2; exit ${2:-1}; } 15 | dieSyntax() { echo "$kTHIS_NAME: ARGUMENT ERROR: ${1:-"Invalid argument(s) specified."} Use -h for help." 1>&2; exit 2; } 16 | 17 | # SYNOPSIS 18 | # openUrl 19 | # DESCRIPTION 20 | # Opens the specified URL in the system's default browser. 21 | openUrl() { 22 | local url=$1 platform=$(uname) cmd=() 23 | case $platform in 24 | 'Darwin') # OSX 25 | cmd=( open "$url" ) 26 | ;; 27 | 'CYGWIN_'*) # Cygwin on Windows; must call cmd.exe with its `start` builtin 28 | cmd=( cmd.exe /c start '' "$url " ) # !! Note the required trailing space. 29 | ;; 30 | 'MINGW32_'*) # MSYS or Git Bash on Windows; they come with a Unix `start` binary 31 | cmd=( start '' "$url" ) 32 | ;; 33 | *) # Otherwise, assume a Freedesktop-compliant OS, which includes many Linux distros, PC-BSD, OpenSolaris, ... 34 | cmd=( xdg-open "$url" ) 35 | ;; 36 | esac 37 | "${cmd[@]}" || { echo "Cannot locate or failed to open default browser; please go to '$url' manually." >&2; return 1; } 38 | } 39 | 40 | # Prints the embedded Markdown-formatted man-page source to stdout. 41 | printManPageSource() { 42 | sed -n -e $'/^: <<\'EOF_MAN_PAGE\'/,/^EOF_MAN_PAGE/ { s///; t\np;}' "$BASH_SOURCE" 43 | } 44 | 45 | # Opens the man page, if installed; otherwise, tries to display the embedded Markdown-formatted man-page source; if all else fails: tries to display the man page online. 46 | openManPage() { 47 | local pager embeddedText 48 | if ! man 1 "$kTHIS_NAME" 2>/dev/null; then 49 | # 2nd attempt: if present, display the embedded Markdown-formatted man-page source 50 | embeddedText=$(printManPageSource) 51 | if [[ -n $embeddedText ]]; then 52 | pager='more' 53 | command -v less &>/dev/null && pager='less' # see if the non-standard `less` is available, because it's preferable to the POSIX utility `more` 54 | printf '%s\n' "$embeddedText" | "$pager" 55 | else # 3rd attempt: open the the man page on the utility's website 56 | openUrl "${kTHIS_HOMEPAGE}/doc/${kTHIS_NAME}.md" 57 | fi 58 | fi 59 | } 60 | 61 | # Prints the contents of the synopsis chapter of the embedded Markdown-formatted man-page source for quick reference. 62 | printUsage() { 63 | local embeddedText 64 | # Extract usage information from the SYNOPSIS chapter of the embedded Markdown-formatted man-page source. 65 | embeddedText=$(sed -n -e $'/^: <<\'EOF_MAN_PAGE\'/,/^EOF_MAN_PAGE/!d; /^## SYNOPSIS$/,/^#/{ s///; t\np; }' "$BASH_SOURCE") 66 | if [[ -n $embeddedText ]]; then 67 | # Print extracted synopsis chapter - remove backticks for uncluttered display. 68 | printf '%s\n\n' "$embeddedText" | tr -d '`' 69 | else # No SYNOPIS chapter found; fall back to displaying the man page. 70 | echo "WARNING: usage information not found; opening man page instead." >&2 71 | openManPage 72 | fi 73 | } 74 | 75 | # --- End: STANDARD HELPER FUNCTIONS 76 | 77 | # --- PROCESS STANDARD, OUTPUT-INFO-THEN-EXIT OPTIONS. 78 | case $1 in 79 | --version) 80 | # Output version number and exit, if requested. 81 | echo "$kTHIS_NAME $kTHIS_VERSION"$'\nFor license information and more, visit '"$kTHIS_HOMEPAGE"; exit 0 82 | ;; 83 | -h|--help) 84 | # Print usage information and exit. 85 | printUsage; exit 86 | ;; 87 | --man) 88 | # Display the manual page and exit, falling back to printing the embedded man-page source. 89 | openManPage; exit 90 | ;; 91 | --man-source) # private option, used by `make update-man` 92 | # Print raw, embedded Markdown-formatted man-page source and exit 93 | printManPageSource; exit 94 | ;; 95 | --home) 96 | # Open the home page and exit. 97 | openUrl "$kTHIS_HOMEPAGE"; exit 98 | ;; 99 | esac 100 | 101 | # ---- Begin: FUNCTIONS 102 | 103 | # See also: getVoiceInternals() 104 | getLegacyVoiceInternals() { 105 | 106 | local internalVoiceName=$1 107 | 108 | # --- Begin: list of numeric creator and voice IDs for *legacy* voices. 109 | # Note: Obtained by systematically making each legacy voice that is preinstalled on a US-English OS X 10.8.3 the default voice 110 | # and then examining ~/Library/Preferences/com.apple.speech.voice.prefs.plist 111 | # Legacy voices are those that do not have VoiceAttributes/VoiceSynthesizerNumericID and VoiceAttributes:VoiceNumericID keys in their 112 | # respective /System/Library/Speech/Voices/${voiceNameNoSpaces}.SpeechVoice/Contents/Info.plist files. 113 | # !! There is 1 EXCEPTION: The voice that System Preferences and its preferences file call "Pipe Organ" is just named 114 | # !! "Organ" in the actual voice bundle's path and Info.plist file. 115 | VoiceCreator_Agnes=1734437985 116 | VoiceID_Agnes=300 117 | VoiceCreator_Albert=1836346163 118 | VoiceID_Albert=41 119 | VoiceCreator_Alex=1835364215 120 | VoiceID_Alex=201 121 | VoiceCreator_BadNews=1836346163 122 | VoiceID_BadNews=36 123 | VoiceCreator_Bahh=1836346163 124 | VoiceID_Bahh=40 125 | VoiceCreator_Bells=1836346163 126 | VoiceID_Bells=26 127 | VoiceCreator_Boing=1836346163 128 | VoiceID_Boing=16 129 | VoiceCreator_Bruce=1734437985 130 | VoiceID_Bruce=100 131 | VoiceCreator_Bubbles=1836346163 132 | VoiceID_Bubbles=50 133 | VoiceCreator_Cellos=1836346163 134 | VoiceID_Cellos=35 135 | VoiceCreator_Deranged=1836346163 136 | VoiceID_Deranged=38 137 | VoiceCreator_Fred=1836346163 138 | VoiceID_Fred=1 139 | VoiceCreator_GoodNews=1836346163 140 | VoiceID_GoodNews=39 141 | VoiceCreator_Hysterical=1836346163 142 | VoiceID_Hysterical=30 143 | VoiceCreator_Junior=1836346163 144 | VoiceID_Junior=4 145 | VoiceCreator_Kathy=1836346163 146 | VoiceID_Kathy=2 147 | VoiceCreator_Organ=1836346163 # !! Shows up as "*Pipe *Organ" in System Preferences and preferences file. 148 | VoiceID_Organ=31 149 | VoiceCreator_Princess=1836346163 150 | VoiceID_Princess=3 151 | VoiceCreator_Ralph=1836346163 152 | VoiceID_Ralph=5 153 | VoiceCreator_Trinoids=1836346163 154 | VoiceID_Trinoids=9 155 | VoiceCreator_Vicki=1835364215 156 | VoiceID_Vicki=200 157 | VoiceCreator_Victoria=1734437985 158 | VoiceID_Victoria=200 159 | VoiceCreator_Whisper=1836346163 160 | VoiceID_Whisper=6 161 | VoiceCreator_Zarvox=1836346163 162 | VoiceID_Zarvox=8 163 | # --- End: list of numeric creator and voiced IDs for *legacy* voices 164 | 165 | vName_VoiceCreator="VoiceCreator_$internalVoiceName" 166 | vName_VoiceID="VoiceID_$internalVoiceName" 167 | 168 | VoiceCreator=${!vName_VoiceCreator} 169 | VoiceID=${!vName_VoiceID} 170 | 171 | } 172 | 173 | 174 | # Determines the internal identifiers of a voice, given as its friendly name, 175 | # as (partially) needed to set a given voice as the default voice. 176 | # *Sets* the following *script-global variables*: 177 | # InternalVoiceName 178 | # VoiceCreator 179 | # VoiceID 180 | # BundleID 181 | getVoiceInternals() { 182 | 183 | local friendlyVoiceName=$1 plistFile internalVoiceName 184 | 185 | # Get the internal voice name - note that this one may not be case-exact, 186 | # which is why we extract the exact case from the Info.plist file below 187 | # and store in global var. $InternalVoiceName (note the uppercase first letter). 188 | internalVoiceName=$(friendlyToInternalVoiceName "$friendlyVoiceName") 189 | 190 | # Locate the voice-specific Info.plist file (as of OS X 10.8.3) 191 | # !! We assume a case-insensitive filesystem. 192 | plistFile="/System/Library/Speech/Voices/${internalVoiceName}.SpeechVoice/Contents/Info.plist" 193 | # !! As of at least 10.10, there are compressed variants that have root folder-name suffix 'Compact'. 194 | # !! These are lower-quality versions with smaller footprint; we use them only if the higher-quality ones aren't available. 195 | [[ ! -f $plistFile ]] && plistFile="/System/Library/Speech/Voices/${internalVoiceName}Compact.SpeechVoice/Contents/Info.plist" 196 | 197 | # If (ultimately) not found, abort. 198 | [[ -f $plistFile ]] || die "'$friendlyVoiceName' is not an installed voice." 199 | 200 | # Determine the relevant IDs we need to switch the default voice. 201 | # Note: We're setting *script-global* variables here. 202 | InternalVoiceName=$(/usr/libexec/PlistBuddy -c "print :CFBundleName" $plistFile) || die "Voice '$friendlyVoiceName': failed to obtain internal voice name." 203 | # !! For *compact* voices, $InternalVoiceNames will have suffix 'Compact', which we remove here, because 204 | # !! this suffix shows up nowhere else. 205 | # !! Key CFBundleName contains the same value as key VoiceName; however, only recent voices have the latter. 206 | # !! Similarly, only recent voices have key VoiceNameRoot, which, in the case of compact voices, also contains the voice name with suffix 'Compact' removed. 207 | InternalVoiceName=${InternalVoiceName%Compact} 208 | 209 | VoiceCreator=$(/usr/libexec/PlistBuddy -c "print :VoiceAttributes:VoiceSynthesizerNumericID" "$plistFile" 2>/dev/null) 210 | if [[ $? -ne 0 ]]; then # Must be a *legacy* voice - we take VoiceCreator and VoiceID from a hard-coded list. 211 | getLegacyVoiceInternals "$InternalVoiceName" 212 | [[ -n $VoiceCreator && -n $VoiceID ]] || die "Voice '$friendlyVoiceName': failed to obtain numeric creator and/or voice IDs." 213 | else 214 | VoiceID=$(/usr/libexec/PlistBuddy -c "print :VoiceAttributes:VoiceNumericID" "$plistFile" 2>/dev/null) || die "Voice '$friendlyVoiceName': failed to obtain numeric voice ID." 215 | fi 216 | 217 | BundleID=$(/usr/libexec/PlistBuddy -c "print :CFBundleIdentifier" $plistFile) || die "Voice '$friendlyVoiceName': failed to obtain bundle ID." 218 | 219 | } 220 | 221 | 222 | 223 | # List all *installed* voices (whether active or not). 224 | # Returns the output from `say -v \?`. 225 | listInstalledVoices() { 226 | say -v \? || die "Failed to list installed voices." 227 | } 228 | 229 | 230 | # List all *active* voices (typically a *subset* of all installed voices, selected by the user for active use via System Preferenes > Dictation & Speech). 231 | # Returns filtered output from `say -v \?`. 232 | listActiveVoices() { 233 | listInstalledVoices | grep -Ei "$(printf '^%s \n' "$(getActiveVoiceNames)")" 234 | } 235 | 236 | # SYNOPSIS 237 | # getVoiceNamesByLangId [-a] langIdPrefix... 238 | # DESCRIPTION 239 | # Returns the friendly names of all active (by default) or installed (-a) voices. 240 | # whose language ID matches the specified language-ID prefixes (case-insensitively). 241 | getVoiceNamesByLangId() { 242 | local allInstalled=0 243 | [[ $1 == '-a' ]] && { allInstalled=1; shift; } 244 | # The output of listActiveVoices/listInstalledVoices - via `say -v \?` - is # 245 | # The difficulty is that friendlyVoiceName may contain embedded spaces, so we need to match accordingly. 246 | # On output we separate by $'\t' to simplify lang-ID matching and returning only the 1st field (the voice name). 247 | { (( allInstalled )) && listInstalledVoices || listActiveVoices; } | sed -E 's/^(.*[^ ]) +([^ #]+) +#/\1'$'\t''\2/' | grep -Ei "$(printf '\t%s.*\n' "$@")" | cut -d $'\t' -f1 248 | } 249 | 250 | # Prints the internal identifiers for the specified voice in the following form: 251 | # "InternalVoiceName= VoiceCreator= VoiceID=" 252 | printVoiceInternals() { 253 | getVoiceInternals "$1" 254 | local v result='' 255 | for v in InternalVoiceName VoiceCreator VoiceID BundleID; do 256 | [[ -z $result ]] && result="$v=${!v}" || result+=" $v=${!v}" 257 | done 258 | echo "$result" 259 | } 260 | 261 | # Outputs the friendly voice names of all *installed* voices, irrespective of whether the 262 | # user chose them for active use by placing a checkmark next to them in System Preferences > Dictation & Speech. 263 | getInstalledVoiceNames() { 264 | # say -v \? prints all installed voices with their friendly names in the 1st column; the challenge is that the may contain embedded spaces. 265 | say -v \? | sed -E 's/^(.*[^ ]) +([^ #]+) +#.*/\1/' 266 | } 267 | 268 | # Outputs the friendly voice names of those voices that are currently *active*. 269 | # Active voices are the *subset* of all *installed* voices that the user chose to actively work with 270 | # by placing a checkmark next to them in System Preferences > Dictation & Speech 271 | # (the ones that show up directly in the pop-up list - as opposed to the ones only visible when you choose 'Customize...' in that list). 272 | getActiveVoiceNames() { 273 | 274 | local FILE_PREFS="$HOME/Library/Preferences/com.apple.speech.voice.prefs.plist" 275 | # !! As of OS X 10.8.3: The list of voices that are *active by default* (and thus also preinstalled). 276 | local ACTIVE_BY_DEFAULT=$(cat <<'EOF' 277 | com.apple.speech.synthesis.voice.Alex 278 | com.apple.speech.synthesis.voice.Bruce 279 | com.apple.speech.synthesis.voice.Fred 280 | com.apple.speech.synthesis.voice.Kathy 281 | com.apple.speech.synthesis.voice.Vicki 282 | com.apple.speech.synthesis.voice.Victoria 283 | EOF 284 | ) 285 | local activeNonDefaults deactivatedDefaults activeDefaults active 286 | 287 | if [[ -f $FILE_PREFS ]]; then 288 | 289 | local re='^\s+com\.apple\.speech\.synthesis\.voice\.[^ ]+ = ' 290 | 291 | # Get all *explicitly activated* voices, *except those that are active *by default*. 292 | # These are voices that were explicitly selected by the user (and downloaded in the process.) 293 | # Note that we do NOT include voices from the set of those that are active by default (which also may show up with flag value 1 once their status has 294 | # been toggled by user action), as we deal with them later. 295 | activeNonDefaults=$(/usr/libexec/PlistBuddy -c 'print' "$FILE_PREFS" | grep -E "$re"'1$' | awk '{ print $1 }' | fgrep -xv "$ACTIVE_BY_DEFAULT") 296 | 297 | # Get the list of *explicitly deactivated* voices among the *active-by-default* ones. 298 | deactivatedDefaults=$(/usr/libexec/PlistBuddy -c 'print' "$FILE_PREFS" | grep -E "$re"'0$' | awk '{ print $1 }' | fgrep -x "$ACTIVE_BY_DEFAULT") 299 | 300 | 301 | # pv activeNonDefaults deactivatedDefaults 302 | 303 | if [[ -n $deactivatedDefaults ]]; then 304 | # Remove them from the list of active-by-default ones. 305 | # In effect: get the list of those active-by-default voices that are *currently* active. 306 | activeDefaults=$(echo "$ACTIVE_BY_DEFAULT" | fgrep -xv "$deactivatedDefaults") 307 | else 308 | activeDefaults=$ACTIVE_BY_DEFAULT 309 | fi 310 | 311 | # Now merge the activate non-defaults and the non-deactivated active-by-default ones 312 | # to yield the effective list of active voices: 313 | active=$activeDefaults 314 | [[ -n $active ]] && active+=$'\n' 315 | active+=$activeNonDefaults 316 | 317 | else 318 | # No prefs. file (pristine installation of OSX): use the defaults. 319 | active=$ACTIVE_BY_DEFAULT 320 | fi 321 | 322 | # Extract the internal names from the bundle IDs - note that premium voices have ".premium" as a suffix - 323 | # and output the friendly equivalents of the internal names. 324 | echo "$active" | awk -F '\\.' '{ sub(/\.premium$/, ""); print $NF }' | internalToFriendlyVoiceName 325 | } 326 | 327 | # SYNOPSIS 328 | # internalToFriendlyVoiceName [internalName...] 329 | # DESCRIPTION 330 | # Translates internal voice names to friendly voice names. 331 | # Internal names may be supplied as operands or via stdin (line by line). 332 | # Output is always line-based, with each friendly voice name output on its own line. 333 | # 334 | # Internal voice names occur in the following places: 335 | # - as part of bundle IDs stored in keys inside ~/Library/Preferences/com.apple.speech.voice.prefs.plist 336 | # - as folder names in /System/Library/Speech/Voices/{internalVoiceName}(Compact)?.SpeechVoice 337 | # - in these folders' ./Contents/Info.plist files as the values of CFBundleName/VoiceName/VoiceNameRoot keys 338 | # - VoiceNameRoot, if present contains the mere internal voice name (stripped of any 'Compact' suffix) 339 | # - VoiceName, if present, and CFBundleName do have the 'Compact' suffix for low-quality voices, if applicable. 340 | # - Legacy voices only have the CFBundleName key, without ever having suffix 'Compact'. 341 | # 342 | # Friendly voice names occur in the following places: 343 | # - in System Preferences, in the TTS (Dictation & Speech) and VoiceOver (Accessibility) settings 344 | # - in the output of `say -v \?` 345 | # - in the 'SelectedVoiceName' value of the com.apple.speech.voice.prefs preferences file. 346 | # 347 | # !! Translation is simplified based on the following assumptions: 348 | # !! A friendly name is the same as the internal name except for the following legacy novelty voices: 349 | # !! Organ -> 'Pipe Organ' 350 | # !! GoodNews -> 'Good News' 351 | # !! These mappings aren't stored explicitly anywhere I could discover; with 'GoodNews' one could suspect word separation 352 | # !! based on camel-case, but that doesn't apply to 'Organ'. 353 | # !! Note that we assume that friendly voice names are case-INsensitive so that extracting internal and ultimately friendly 354 | # !! names from bundle ID - which are typically all-lowercase - is acceptable. E.g., the assumption is that the system 355 | # !! treats 'anna' the same as 'Anna'. 356 | # !! Note that guessing the case based on capitalizing the initial letter and the 1st letter following a '-' would not work in all cases: 357 | # !! cf. 'Mei-Jia' (Tawain) and 'Sin-ji' (Hong Kong). To truly get the friendly name, it would have to be derived from 358 | # !! multiple keys in /System/Library/Speech/Voices/{internalVoiceName}(Compact)?.SpeechVoice/Contents/Info.plist. 359 | internalToFriendlyVoiceName() ( 360 | shopt -s nocasematch 361 | while read -r internalName; do 362 | case $internalName in 363 | organ) 364 | echo "Pipe Organ" 365 | ;; 366 | goodnews) 367 | echo "Good News" 368 | ;; 369 | *) 370 | echo "$internalName" 371 | ;; 372 | esac 373 | done < <( (( $# > 0 )) && printf '%s\n' "$@" || cat ) 374 | ) 375 | 376 | # Inverse of internalToFriendlyVoiceName() 377 | friendlyToInternalVoiceName() { 378 | 379 | # The internal voice name is generally just the friendly one with spaces removed. 380 | # Only 2 voices, which are legacy voices, have spaces in their friendly names: 'Good News' and 'Pipe Organ' 381 | # Presumably, no future voices will have embedded spaces. 382 | local internalVoiceName=${1// /} 383 | 384 | # There's one exception: friendly name 'Pipe Organ' maps to just 'Organ'. 385 | case $internalVoiceName in 386 | 'PipeOrgan') 387 | internalVoiceName='Organ' 388 | ;; 389 | esac 390 | 391 | printf '%s\n' "$internalVoiceName" 392 | 393 | } 394 | 395 | # Caches the custom speaking rates from com.apple.speech.voice.prefs for *this shell session only*. 396 | # in global variable 397 | # $customSpeakingRates 398 | # $customSpeakingRates is filled - once for this shell - as follows: 399 | # Get all custom speaking rates (words per minute) from the preferences file - on a pristine system, not even the file may exist, let alone custom rates). 400 | # Strip all chars. so that only (voice-creator, voice-ID, custom-rate) line triplets remain; e.g.: 401 | # 1886745202 # voice creator 402 | # 184844493 # voice ID 403 | # 200 # speaking rate; a value *roughly* >= 90 <= 360 404 | getCachedCustomSpeakingRates() { 405 | [[ -z $customSpeakingRates && -n ${customSpeakingRates-unset} ]] && customSpeakingRates=$(defaults read com.apple.speech.voice.prefs VoiceRateDataArray 2>/dev/null | tr -d '() ,' | sed '/^$/d' ) 406 | } 407 | 408 | # Outputs the custom speaking rate for the specified voice, if it is defined - range is *roughly* between 90 and 360 - apparently, it's possible to at least get slightly lower. 409 | # If not defined, outputs nothing. 410 | getCustomSpeakingRate() { 411 | local voice=$1 customRate 412 | 413 | # Set global variable $customSpeakingRates to contain any defined custom-speaking rates as 414 | # (voice-creator, voice-ID, custom-rate) line triplets. 415 | getCachedCustomSpeakingRates 416 | 417 | if [[ -n $customSpeakingRates ]]; then # short-circuit if there are no custom speaking rates at all 418 | 419 | # Get (cached) internal identifiers for the target voice. 420 | # NOTE: This is fairly time-consuming operation. 421 | # This sets global variables InternalVoiceName, VoiceCreator, VoiceID 422 | getVoiceInternals "$voice" 423 | 424 | # Extract the custom speaking rate, if any, for the target voice. 425 | customRate=$(awk -v first="$VoiceCreator" -v second="$VoiceID" '$1 == second && prev == first { getline; print $1; exit } { prev = $1 }' <<<"$customSpeakingRates") 426 | 427 | echo "$customRate" 428 | 429 | fi 430 | } 431 | 432 | 433 | # SYNOPSIS 434 | # speakText friendlyVoiceName [text] 435 | # If is missing or empty, the demo text is spoken. 436 | speakText() { 437 | local friendlyVoiceName=$1 text=$2 rateOpts 438 | 439 | if [[ -z $text ]]; then # No text specified? Use demo text. 440 | text=$(say -v \? | egrep -i "^$friendlyVoiceName +[a-z]{2}[_-]\w+ +#" | awk -F '#' '{ print $2; }') 441 | fi 442 | 443 | # !! Sadly, as of OSX 10.11, `say` doesn't respect custom speaking rates defined in System Preferences 444 | # !! when used with an explicit voice name (-v) (reported to Apple, 445 | # !! so we have to extract the custom rates ourselves and specify them explicitly with -r. 446 | # !! Should `say` ever become custom-rate aware, this will no longer be needed. 447 | rateOpts=() 448 | customRate=$(getCustomSpeakingRate "$friendlyVoiceName") 449 | (( customRate > 0 )) && rateOpts=( -r "$customRate" ) 450 | 451 | say -v "$friendlyVoiceName" "${rateOpts[@]}" -- "$text" 452 | } 453 | 454 | openTtsSystemPrefs() { 455 | osascript <<'EOF' 456 | set AppleScript's text item delimiters to "." 457 | set minorOsNum to text item 2 of system version of (system info) as number 458 | tell application "System Preferences" 459 | if minorOsNum ≥ 12 then # 10.12+ (Sierra+) 460 | reveal anchor "TextToSpeech" of pane "com.apple.preference.universalaccess" 461 | else # 10.11- (El Capitan-) 462 | reveal anchor "TTS" of pane "com.apple.preference.speech" 463 | end if 464 | activate 465 | end tell 466 | EOF 467 | } 468 | 469 | getDefaultVoiceName() { 470 | # Note: SelectedVoiceName actually contains the *friendly*, not the internal name. 471 | defaults read com.apple.speech.voice.prefs SelectedVoiceName 472 | } 473 | 474 | # setDefaultVoice friendlyVoiceName 475 | setDefaultVoice() { 476 | 477 | local friendlyVoiceName=$1 478 | 479 | # Determine the specified voice's internal identifiers. 480 | # Note that getVoiceInternals() sets shell-global variables $InternalVoiceName, $VoiceID, and $VoiceCreator. 481 | getVoiceInternals "$friendlyVoiceName" || return 482 | 483 | # Write the identifiers for the new default voice. 484 | defaults write com.apple.speech.voice.prefs 'SelectedVoiceCreator' -int $VoiceCreator || die 485 | defaults write com.apple.speech.voice.prefs 'SelectedVoiceID' -int $VoiceID || die 486 | # Note: SelectedVoiceName actually contains the *friendly*, not the internal name. Case does NOT matter. 487 | defaults write com.apple.speech.voice.prefs 'SelectedVoiceName' -string "$friendlyVoiceName" || die 488 | 489 | # Sadly, there's no official way to notify the system of a change in default voice, as the only official way 490 | # to change the default voice is to use System Preferences interactively. 491 | # Simply updating defaults is NOT enough for the text-to-speech feature to pick up the change 492 | # - only `say` does. 493 | # Without further action, text-to-speech would only pick up the change on next reboot or after logging out and back in. 494 | # An effective workaround is to kill the the per-user speech-synthesis server, which causes 495 | # the system to instantly restart it - at which point the new settings are read and take effect. 496 | # We keep our fingers crossed that the name and location of the speech-synthesis server, SpeechSynthesisServer.app, 497 | # does not change in future OSX versions. 498 | # The current name was obtained on OSX 10.10.3 as follows: 499 | # Activity Monitor > search for 'speech'. 500 | # Note that it is the speech-synthesis server *daemon*, com.apple.speech.speechsynthesisd, that has the current default voice open 501 | # if you inspect the Open Files and Ports tab. 502 | # It is tempting, to simply run pkill com.apple.speech.speechsynthesisd and let the system restart the process, but that does NOT 503 | # fully work: while changing the voice per se is effective, *custom speaking rates for the voices are NOT honored& - 504 | # whatever rate was last active lingers. 505 | # **Thus, it is the speech-synthesis *server* we must kill and manually restart.** 506 | # Tip of the hat to http://stackoverflow.com/a/27776019/45375 507 | pkill -x SpeechSynthesisServer &>/dev/null 508 | # The following path is the abstracted version - using the system-installed symlinks such as 'Current' that point to the active location - of: 509 | # /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/SpeechSynthesis.framework/Versions/A/SpeechSynthesisServer.app 510 | # The actual process command-line launched by the `open` command below looks like this: 511 | # /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/SpeechSynthesis.framework/Versions/A/SpeechSynthesisServer.app/Contents/MacOS/SpeechSynthesisServer launchd 512 | open /System/Library/Frameworks/ApplicationServices.framework/Frameworks/SpeechSynthesis.framework/Versions/Current/SpeechSynthesisServer.app || cat <&2 513 | WARNING: 514 | Failed to restart the speech-synthesis server. 515 | While the \`say\` utility will reflect the new default voice instantly, 516 | the text-to-speech feature may not use the new voice until after a reboot. 517 | EOF 518 | 519 | } 520 | 521 | # ---- End: FUNCTIONS 522 | 523 | 524 | # ---- MAIN BODY 525 | 526 | # ----- BEGIN: OPTIONS PARSING: This is MOSTLY generic code, but: 527 | # Option-parameters loop. 528 | default=0 529 | listLangs=0 530 | list=0 531 | allInstalled=0 532 | bare=0 533 | internals=0 534 | validateVoiceNames=0 535 | speak=0 536 | manage=0 537 | quiet=0 538 | text='' 539 | allowOptsAfterOperands=1 operands=() i=0 optName= isLong=0 prefix= optArg= haveOptArgAttached=0 haveOptArgAsNextArg=0 acceptOptArg=0 needOptArg=0 540 | while (( $# )); do 541 | if [[ $1 =~ ^(-)[a-zA-Z0-9]+.*$ || $1 =~ ^(--)[a-zA-Z0-9]+.*$ ]]; then # an option: either a short option / multiple short options in compressed form or a long option 542 | prefix=${BASH_REMATCH[1]}; [[ $prefix == '--' ]] && isLong=1 || isLong=0 543 | for (( i = 1; i < (isLong ? 2 : ${#1}); i++ )); do 544 | acceptOptArg=0 needOptArg=0 haveOptArgAttached=0 haveOptArgAsNextArg=0 optArgAttached= optArgOpt= optArgReq= 545 | if (( isLong )); then # long option: parse into name and, if present, argument 546 | optName=${1:2} 547 | [[ $optName =~ ^([^=]+)=(.*)$ ]] && { optName=${BASH_REMATCH[1]}; optArgAttached=${BASH_REMATCH[2]}; haveOptArgAttached=1; } 548 | else # short option: *if* it takes an argument, the rest of the string, if any, is by definition the argument. 549 | optName=${1:i:1}; optArgAttached=${1:i+1}; (( ${#optArgAttached} >= 1 )) && haveOptArgAttached=1 550 | fi 551 | (( haveOptArgAttached )) && optArgOpt=$optArgAttached optArgReq=$optArgAttached || { (( $# > 1 )) && { optArgReq=$2; haveOptArgAsNextArg=1; }; } 552 | # ---- BEGIN: CUSTOMIZE HERE 553 | case $optName in 554 | d|default|set-default) 555 | default=1 556 | ;; 557 | m|manage) 558 | manage=1 559 | ;; 560 | L|list-langs) 561 | listLangs=1 562 | ;; 563 | l|list) 564 | list=1 565 | ;; 566 | a|all) 567 | allInstalled=1 568 | ;; 569 | i|internals) 570 | internals=1 571 | ;; 572 | b|bare) 573 | bare=1 574 | ;; 575 | k|speak|speak=*) 576 | acceptOptArg=1 577 | speak=1 578 | text=$optArgOpt 579 | # If text is '-', read from stdin. 580 | [[ $text == '-' ]] && text=$( 0 )) && set -- "${operands[@]}"; unset allowOptsAfterOperands operands i optName isLong prefix optArgAttached haveOptArgAttached haveOptArgAsNextArg acceptOptArg needOptArg 606 | # ----- END: OPTIONS PARSING: "$@" now contains all operands (non-option arguments). 607 | 608 | # Check for incompatible options and validate number of operands. 609 | errMsg="Incompatible options specified." 610 | if (( manage )); then 611 | (( $# == 0 )) || dieSyntax 612 | (( (default + allInstalled + listLangs + list + speak + bare + internals) == 0 )) || dieSyntax "$errMsg" 613 | elif (( listLangs )); then 614 | (( $# == 0 )) || dieSyntax 615 | (( (default + list + speak + bare + internals + quiet) == 0 )) || dieSyntax "$errMsg" 616 | else 617 | (( allInstalled && ! (list || $# > 0) )) && dieSyntax "$errMsg" # Note: we tolerate -a when explicit voice names are specified, even though it's implied. 618 | (( bare && internals)) && dieSyntax "$errMsg" 619 | if (( quiet && ! speak )); then # -q to quiet printed output always makes sense when speaking is requested 620 | (( list )) && dieSyntax "$errMsg" # with listing voices, -q makes no sense 621 | (( default && $# == 1 )) || dieSyntax "$errMsg" # -q does make sense when setting a new default voice. 622 | fi 623 | fi 624 | errMsg= 625 | 626 | # -- Handle the exceptional synopsis forms first. 627 | 628 | if (( manage )); then 629 | openTtsSystemPrefs 630 | exit 0 631 | elif (( listLangs )); then 632 | # List the distinct, sorted set of language IDs only - by default among the active voices only, on request (-a) among all installed voices. 633 | { (( allInstalled )) && listInstalledVoices || listActiveVoices; } | egrep -o ' [a-z]{2}[_-]\w+ +#' | awk '{ print $1 }' | sort -u 634 | (( ${PIPESTATUS[0]} == 0 )) || die 635 | exit 0 636 | fi 637 | 638 | # -- Getting here means that one of the following command forms was specified: 639 | # [-d [newDefault]] 640 | # -l 641 | # voiceName... 642 | 643 | 644 | # -- Validate operands and prepare for actual processing later. 645 | if (( list )); then 646 | # Translate the language IDs, if any, to matching voice names. 647 | # If no language ID was specified, getVoiceNamesByLangId returns ALL installed/active voices. 648 | IFS=$'\n' read -d '' -ra voiceNames < <(getVoiceNamesByLangId $( (( allInstalled )) && printf %s '-a') "$@") 649 | (( ${#voiceNames[@]} > 0 )) || die "No installed voices match the specified languages, $*." 650 | set -- "${voiceNames[@]}" # set the resulting voices as operands to be processed below. 651 | elif (( default || $# == 0 )); then # get or set default voice 652 | if (( $# == 1 )); then # set new default voice 653 | setDefaultVoice "$1" || die 654 | (( quiet )) || echo "Default voice changed to:" 655 | # Leave the new default voice name as $1, because we will print information about it and/or speak text below. 656 | elif (( $# == 0 )); then # get current default voice 657 | # Set the current default voice name as $1, because we will print information about it and/or speak text below. 658 | set -- "$(getDefaultVoiceName)" 659 | if [[ -z $1 ]]; then 660 | cat <&2; 661 | ERROR: Failed to determine the default voice. 662 | This typically happens on a pristine system where the default voice has 663 | never been changed. 664 | Once you've changed it for the first time, $kTHIS_NAME will be able to 665 | determine it. You can change it with \`$kTHIS_NAME -d \`, or 666 | interactively via System Preferences (\`$kTHIS_NAME -m\`). 667 | EOF 668 | exit 1 669 | fi 670 | else # too many arguments 671 | dieSyntax 672 | fi 673 | else # explicit voice names were specified - they must be validated 674 | validateVoiceNames=1 675 | fi 676 | 677 | # The list of target voices - whether directly specified or derived above - 678 | # if any, is now contained in $@, and what's left is to print information 679 | # about each and, if requested, speak text for each. 680 | 681 | 682 | okCount=0 allVoicesList= infoLine= 683 | for voice; do 684 | 685 | # Validate the voice, if needed and/or get the info line for the voice at hand. 686 | infoLine= 687 | if (( validateVoiceNames || (speak && ${#text} == 0) || ! (bare || internals) )); then 688 | # Get and cache the list of all installed voices, as output by `say -v \?`. 689 | [[ -z $allVoicesList ]] && { allVoicesList=$(listInstalledVoices) || die; } 690 | # Note: This command both validates the voice name and returns the relevant `say -v \?` info line for potential later use. 691 | infoLine=$(grep -Ei "^$voice +[a-z]{2}[_-]\w+ +#" <<<"$allVoicesList") || { echo "WARNING: '$voice' is not an installed voice." >&2; continue; } 692 | fi 693 | 694 | # Output: 695 | if (( ! quiet )); then 696 | if (( bare )); then # print friendly voice *name* only 697 | printf '%s\n' "$voice" 698 | elif (( internals )); then # print the voice's internal identifiers 699 | printVoiceInternals "$voice" 700 | else # print the voice-specific line as output by `say -v \?`. 701 | printf '%s\n' "$infoLine" 702 | fi 703 | fi 704 | 705 | # Speak: If requested, also speak text for the voice at hand. 706 | if (( speak )); then 707 | # Speak specified or demo text. 708 | speakText "$voice" "$([[ -n $text ]] && printf %s "$text" || printf %s "${infoLine##*\#}" )" 709 | fi 710 | 711 | (( ++okCount )) 712 | 713 | done 714 | 715 | # Exit with 0, if at least one voice was successfully processed. 716 | (( okCount > 0 )) && exit 0 || exit 1 717 | 718 | #### 719 | # MAN PAGE MARKDOWN SOURCE 720 | # - Place a Markdown-formatted version of the man page for this script 721 | # inside the here-document below. 722 | # The document must be formatted to look good in all 3 viewing scenarios: 723 | # - as a man page, after conversion to ROFF with marked-man 724 | # - as plain text (raw Markdown source) 725 | # - as HTML (rendered Markdown) 726 | # Markdown formatting tips: 727 | # - GENERAL 728 | # To support plain-text rendering in the terminal, limit all lines to 80 chars., 729 | # and, for similar rendering as HTML, *end every line with 2 trailing spaces*. 730 | # - HEADINGS 731 | # - For better plain-text rendering, leave an empty line after a heading 732 | # marked-man will remove it from the ROFF version. 733 | # - The first heading must be a level-1 heading containing the utility 734 | # name and very brief description; append the manual-section number 735 | # directly to the CLI name; e.g.: 736 | # # foo(1) - does bar 737 | # - The 2nd, level-2 heading must be '## SYNOPSIS' and the chapter's body 738 | # must render reasonably as plain text, because it is printed to stdout 739 | # when `-h`, `--help` is specified: 740 | # Use 4-space indentation without markup for both the syntax line and the 741 | # block of brief option descriptions; represent option-arguments and operands 742 | # in angle brackets; e.g., '' 743 | # - All other headings should be level-2 headings in ALL-CAPS. 744 | # - TEXT 745 | # - Use NO indentation for regular chapter text; if you do, it will 746 | # be indented further than list items. 747 | # - Use 4-space indentation, as usual, for code blocks. 748 | # - Markup character-styling markup translates to ROFF rendering as follows: 749 | # `...` and **...** render as bolded (red) text 750 | # _..._ and *...* render as word-individually underlined text 751 | # - LISTS 752 | # - Indent list items by 2 spaces for better plain-text viewing, but note 753 | # that the ROFF generated by marked-man still renders them unindented. 754 | # - End every list item (bullet point) itself with 2 trailing spaces too so 755 | # that it renders on its own line. 756 | # - Avoid associating more than 1 paragraph with a list item, if possible, 757 | # because it requires the following trick, which hampers plain-text readability: 758 | # Use ' ' in lieu of an empty line. 759 | #### 760 | : <<'EOF_MAN_PAGE' 761 | # voices(1) - OS X text-to-speech voices 762 | 763 | ## SYNOPSIS 764 | 765 | Get or set or speak with the DEFAULT VOICE: 766 | 767 | voices [] [-d []] 768 | 769 | LIST INFORMATION about / speak with voices: 770 | 771 | voices [] ... 772 | 773 | List / speak with ALL VOICES, optionally FILTERED BY LANGUAGES: 774 | 775 | voices [] -l [...] 776 | 777 | LIST LANGUAGES among voices: 778 | 779 | voices -L [-a] 780 | 781 | MANAGE VOICES in System Preferences: 782 | 783 | voices -m 784 | 785 | Shared options (synopsis forms 1-3): 786 | 787 | -a target all installed voices (default: only active ones) 788 | -k speak demo text with all targeted voices 789 | -k"" speak specified text 790 | -k- speak text provided via stdin 791 | -b output format: print voice names only 792 | -i output format: print voice internals 793 | -q quiet mode: no printed output 794 | 795 | Standard options: `--help`, `--man`, `--version`, `--home` 796 | 797 | ## DESCRIPTION 798 | 799 | `voices` sets the default voice for OS X's TTS (text-to-speech) synthesis or 800 | returns information about the default, active and installed voices. 801 | Additionally, it can speak either the demo text or specified text with 802 | multiple voices. 803 | 804 | Case doesn't matter when specifying voice or language names. 805 | 806 | * Specify voice names as they appear in System Preferences > 807 | Dictation & Speeech and in the output from `say -v \?`. 808 | 809 | * Specify languages as two-character language IDs (e.g., `en`), optionally 810 | followed by `_` and a region identifier (e.g., `en_US`). 811 | 812 | Options `-l` and `-L` target all *active* voices by default, which are 813 | typically a a subset of all *installed* voices, and constitute the set of 814 | voices selected for active use in System Preferences > Dictation & Speech > 815 | Text to Speech. 816 | Adding `-a` targets all installed voices. 817 | 818 | The `-k` option for speaking with all targeted voices as well as other 819 | shared options are discussed further below. Without `-k`, only printed output 820 | is produced; conversely, `-q` silences printed output. 821 | 822 | * 1st synopsis form: `[-d []]`, `[--default []]` 823 | Returns information about the default voice or sets a new default voice. 824 | Note that any installed voice can be specified as the default voice, even 825 | if it is not among the set of active voices. 826 | 827 | * 2nd synopsis form: `...` 828 | Lists information about the specified voices (whether active or not). 829 | 830 | * 3rd synopsis form: `-l [...]`, `--list [...]` 831 | Lists information about active, installed, or voices matching one or more 832 | specified languages. 833 | Lists all active voices by default; `-a` lists all installed ones. 834 | If at least one `` operand is given, the list of active voices (by 835 | default) / installed voices (with `-a`) is filtered to output only those 836 | matching the specified language(s). 837 | `` values may be mere language IDs (e.g., `en`) or language + region 838 | IDs (e.g., `en_US`); e.g., `en` matches all English voices irrespective of 839 | region, whereas `en_US` matches only US English voices. 840 | 841 | * 4th synopsis form: `-L`, `--list-langs` 842 | Lists the distinct set of languages supported among all active (by default) 843 | or all installed (`-a`) voices. 844 | Languages are listed as language + region identifiers, e.g., `en_US`. 845 | 846 | * 5th synopsis form: `-m`, `--manage` 847 | Opens System Preferences > Dictation & Speech, where you can manage the 848 | set of active voices, install additional voices, and control other aspects 849 | of text-to-speech synthesis. 850 | 851 | ## SHARED OPTIONS 852 | 853 | These options complement the main options, which determine the synopsis form, 854 | discussed above. 855 | 856 | ### General Options 857 | 858 | * `-q` 859 | Quiet mode: suppresses printed output, such as when only speech output 860 | (`-k`) is desired or when the new default voice should be set quietly. 861 | Cannot be combined with `-L`, whose sole purpose is to print 862 | information. 863 | 864 | ### Speaking options (synopsis forms 1-3): 865 | 866 | Note that if the command targets multiple voices, speaking happens 867 | after each voice's information has been printed (unless printing is 868 | suppressed with `-q`). 869 | 870 | * `-k`, `--speak` (no argument) 871 | Speaks each targeted voice's demo text. 872 | 873 | * `-k""`, `--speak=""` 874 | Speaks the specified text using each targeted voice. 875 | Note that `""` must be directly attached to the option and should 876 | generally be quoted to protect it from (unwanted) interpretation by the 877 | shell. 878 | 879 | * `-k-`, `--speak=-` 880 | Speaks text provided via stdin using each targeted voice. 881 | 882 | ### Printed-Output Options (synopsis forms 1-3) 883 | 884 | By default, voice information printed is in the form provided by the standard 885 | `say` utility when invoked as `say -v \?`, which is: 886 | ` # ` 887 | 888 | The following, mutually exclusive options modify this behavior: 889 | 890 | * `-b`, `--bare` 891 | Outputs mere voice names only. 892 | 893 | * `-i`, `--internals` 894 | Outputs internal voice identifiers, as used by the system. 895 | 896 | ## STANDARD OPTIONS 897 | 898 | All standard options must be provided as the only argument; all of them provide 899 | information only. 900 | 901 | * `-h, --help` 902 | Prints the contents of the synopsis chapter to stdout for quick reference. 903 | 904 | * `--man` 905 | Displays this manual page, which is a helpful alternative to using `man`, 906 | if the manual page isn't installed. 907 | 908 | * `--version` 909 | Prints version information. 910 | 911 | * `--home` 912 | Opens this utility's home page in the system's default web browser. 913 | 914 | ## LICENSE 915 | 916 | For license information, bug reports, and more, visit this utility's home page 917 | by running `voices --home` 918 | 919 | ## EXAMPLES 920 | 921 | # List all active voices; add -a to list all installed ones. 922 | voices -l 923 | 924 | # Print information about the default voice and speak its demo text. 925 | voices -d -k 926 | 927 | # Print information about voice 'Alex'. 928 | voices alex 929 | 930 | # Make 'Alex' the new default voice, print information about it, and 931 | # speak text that announces the change. 932 | voices -k'The new default voice is Alex.' -d alex 933 | 934 | # List languages for which at least one voice is active. 935 | voices -L 936 | 937 | # List active French voices. 938 | voices -l fr 939 | 940 | # Speak the respective demo text with all active voices. 941 | voices -l -k 942 | 943 | # Speak "hello" first with Alex, then with Jill, suppressing printed 944 | # output. 945 | voices -k"hello" -q alex jill 946 | 947 | # Print information about all active Spanish voices and speak their 948 | # respective demo text. 949 | voices -k -l es 950 | 951 | EOF_MAN_PAGE 952 | -------------------------------------------------------------------------------- /doc/voices.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # voices(1) - OS X text-to-speech voices 4 | 5 | ## SYNOPSIS 6 | 7 | Get or set or speak with the DEFAULT VOICE: 8 | 9 | voices [] [-d []] 10 | 11 | LIST INFORMATION about / speak with voices: 12 | 13 | voices [] ... 14 | 15 | List / speak with ALL VOICES, optionally FILTERED BY LANGUAGES: 16 | 17 | voices [] -l [...] 18 | 19 | LIST LANGUAGES among voices: 20 | 21 | voices -L [-a] 22 | 23 | MANAGE VOICES in System Preferences: 24 | 25 | voices -m 26 | 27 | Shared options (synopsis forms 1-3): 28 | 29 | -a target all installed voices (default: only active ones) 30 | -k speak demo text with all targeted voices 31 | -k"" speak specified text 32 | -k- speak text provided via stdin 33 | -b output format: print voice names only 34 | -i output format: print voice internals 35 | -q quiet mode: no printed output 36 | 37 | Standard options: `--help`, `--man`, `--version`, `--home` 38 | 39 | ## DESCRIPTION 40 | 41 | `voices` sets the default voice for OS X's TTS (text-to-speech) synthesis or 42 | returns information about the default, active and installed voices. 43 | Additionally, it can speak either the demo text or specified text with 44 | multiple voices. 45 | 46 | Case doesn't matter when specifying voice or language names. 47 | 48 | * Specify voice names as they appear in System Preferences > 49 | Dictation & Speeech and in the output from `say -v \?`. 50 | 51 | * Specify languages as two-character language IDs (e.g., `en`), optionally 52 | followed by `_` and a region identifier (e.g., `en_US`). 53 | 54 | Options `-l` and `-L` target all *active* voices by default, which are 55 | typically a a subset of all *installed* voices, and constitute the set of 56 | voices selected for active use in System Preferences > Dictation & Speech > 57 | Text to Speech. 58 | Adding `-a` targets all installed voices. 59 | 60 | The `-k` option for speaking with all targeted voices as well as other 61 | shared options are discussed further below. Without `-k`, only printed output 62 | is produced; conversely, `-q` silences printed output. 63 | 64 | * 1st synopsis form: `[-d []]`, `[--default []]` 65 | Returns information about the default voice or sets a new default voice. 66 | Note that any installed voice can be specified as the default voice, even 67 | if it is not among the set of active voices. 68 | 69 | * 2nd synopsis form: `...` 70 | Lists information about the specified voices (whether active or not). 71 | 72 | * 3rd synopsis form: `-l [...]`, `--list [...]` 73 | Lists information about active, installed, or voices matching one or more 74 | specified languages. 75 | Lists all active voices by default; `-a` lists all installed ones. 76 | If at least one `` operand is given, the list of active voices (by 77 | default) / installed voices (with `-a`) is filtered to output only those 78 | matching the specified language(s). 79 | `` values may be mere language IDs (e.g., `en`) or language + region 80 | IDs (e.g., `en_US`); e.g., `en` matches all English voices irrespective of 81 | region, whereas `en_US` matches only US English voices. 82 | 83 | * 4th synopsis form: `-L`, `--list-langs` 84 | Lists the distinct set of languages supported among all active (by default) 85 | or all installed (`-a`) voices. 86 | Languages are listed as language + region identifiers, e.g., `en_US`. 87 | 88 | * 5th synopsis form: `-m`, `--manage` 89 | Opens System Preferences > Dictation & Speech, where you can manage the 90 | set of active voices, install additional voices, and control other aspects 91 | of text-to-speech synthesis. 92 | 93 | ## SHARED OPTIONS 94 | 95 | These options complement the main options, which determine the synopsis form, 96 | discussed above. 97 | 98 | ### General Options 99 | 100 | * `-q` 101 | Quiet mode: suppresses printed output, such as when only speech output 102 | (`-k`) is desired or when the new default voice should be set quietly. 103 | Cannot be combined with `-L`, whose sole purpose is to print 104 | information. 105 | 106 | ### Speaking options (synopsis forms 1-3): 107 | 108 | Note that if the command targets multiple voices, speaking happens 109 | after each voice's information has been printed (unless printing is 110 | suppressed with `-q`). 111 | 112 | * `-k`, `--speak` (no argument) 113 | Speaks each targeted voice's demo text. 114 | 115 | * `-k""`, `--speak=""` 116 | Speaks the specified text using each targeted voice. 117 | Note that `""` must be directly attached to the option and should 118 | generally be quoted to protect it from (unwanted) interpretation by the 119 | shell. 120 | 121 | * `-k-`, `--speak=-` 122 | Speaks text provided via stdin using each targeted voice. 123 | 124 | ### Printed-Output Options (synopsis forms 1-3) 125 | 126 | By default, voice information printed is in the form provided by the standard 127 | `say` utility when invoked as `say -v \?`, which is: 128 | ` # ` 129 | 130 | The following, mutually exclusive options modify this behavior: 131 | 132 | * `-b`, `--bare` 133 | Outputs mere voice names only. 134 | 135 | * `-i`, `--internals` 136 | Outputs internal voice identifiers, as used by the system. 137 | 138 | ## STANDARD OPTIONS 139 | 140 | All standard options must be provided as the only argument; all of them provide 141 | information only. 142 | 143 | * `-h, --help` 144 | Prints the contents of the synopsis chapter to stdout for quick reference. 145 | 146 | * `--man` 147 | Displays this manual page, which is a helpful alternative to using `man`, 148 | if the manual page isn't installed. 149 | 150 | * `--version` 151 | Prints version information. 152 | 153 | * `--home` 154 | Opens this utility's home page in the system's default web browser. 155 | 156 | ## LICENSE 157 | 158 | For license information, bug reports, and more, visit this utility's home page 159 | by running `voices --home` 160 | 161 | ## EXAMPLES 162 | 163 | # List all active voices; add -a to list all installed ones. 164 | voices -l 165 | 166 | # Print information about the default voice and speak its demo text. 167 | voices -d -k 168 | 169 | # Print information about voice 'Alex'. 170 | voices alex 171 | 172 | # Make 'Alex' the new default voice, print information about it, and 173 | # speak text that announces the change. 174 | voices -k'The new default voice is Alex.' -d alex 175 | 176 | # List languages for which at least one voice is active. 177 | voices -L 178 | 179 | # List active French voices. 180 | voices -l fr 181 | 182 | # Speak the respective demo text with all active voices. 183 | voices -l -k 184 | 185 | # Speak "hello" first with Alex, then with Jill, suppressing printed 186 | # output. 187 | voices -k"hello" -q alex jill 188 | 189 | # Print information about all active Spanish voices and speak their 190 | # respective demo text. 191 | voices -k -l es 192 | 193 | -------------------------------------------------------------------------------- /man/voices.1: -------------------------------------------------------------------------------- 1 | .TH "VOICES" "1" "March 2018" "v0.3.4" "" 2 | .SH "NAME" 3 | \fBvoices\fR \- OS X text\-to\-speech voices 4 | .SH SYNOPSIS 5 | .P 6 | Get or set or speak with the DEFAULT VOICE: 7 | .P 8 | .RS 2 9 | .nf 10 | voices [] [\-d []] 11 | .fi 12 | .RE 13 | .P 14 | LIST INFORMATION about / speak with voices: 15 | .P 16 | .RS 2 17 | .nf 18 | voices [] \.\.\. 19 | .fi 20 | .RE 21 | .P 22 | List / speak with ALL VOICES, optionally FILTERED BY LANGUAGES: 23 | .P 24 | .RS 2 25 | .nf 26 | voices [] \-l [\.\.\.] 27 | .fi 28 | .RE 29 | .P 30 | LIST LANGUAGES among voices: 31 | .P 32 | .RS 2 33 | .nf 34 | voices \-L [\-a] 35 | .fi 36 | .RE 37 | .P 38 | MANAGE VOICES in System Preferences: 39 | .P 40 | .RS 2 41 | .nf 42 | voices \-m 43 | .fi 44 | .RE 45 | .P 46 | Shared options (synopsis forms 1\-3): 47 | .P 48 | .RS 2 49 | .nf 50 | \-a target all installed voices (default: only active ones) 51 | \-k speak demo text with all targeted voices 52 | \-k"" speak specified text 53 | \-k\- speak text provided via stdin 54 | \-b output format: print voice names only 55 | \-i output format: print voice internals 56 | \-q quiet mode: no printed output 57 | .fi 58 | .RE 59 | .P 60 | Standard options: \fB\-\-help\fP, \fB\-\-man\fP, \fB\-\-version\fP, \fB\-\-home\fP 61 | .SH DESCRIPTION 62 | .P 63 | \fBvoices\fP sets the default voice for OS X's TTS (text\-to\-speech) synthesis or 64 | .br 65 | returns information about the default, active and installed voices\. 66 | .br 67 | Additionally, it can speak either the demo text or specified text with 68 | .br 69 | multiple voices\. 70 | .P 71 | Case doesn't matter when specifying voice or language names\. 72 | .RS 0 73 | .IP \(bu 2 74 | Specify voice names as they appear in System Preferences > 75 | .br 76 | Dictation & Speeech and in the output from \fBsay \-v \\?\fP\|\. 77 | .IP \(bu 2 78 | Specify languages as two\-character language IDs (e\.g\., \fBen\fP), optionally 79 | .br 80 | followed by \fB_\fP and a region identifier (e\.g\., \fBen_US\fP)\. 81 | 82 | .RE 83 | .P 84 | Options \fB\-l\fP and \fB\-L\fP target all \fIactive\fR voices by default, which are 85 | .br 86 | typically a a subset of all \fIinstalled\fR voices, and constitute the set of 87 | .br 88 | voices selected for active use in System Preferences > Dictation & Speech > 89 | .br 90 | Text to Speech\. 91 | .br 92 | Adding \fB\-a\fP targets all installed voices\. 93 | .P 94 | The \fB\-k\fP option for speaking with all targeted voices as well as other 95 | .br 96 | shared options are discussed further below\. Without \fB\-k\fP, only printed output 97 | .br 98 | is produced; conversely, \fB\-q\fP silences printed output\. 99 | .RS 0 100 | .IP \(bu 2 101 | 1st synopsis form: \fB[\-d []]\fP, \fB[\-\-default []]\fP 102 | .br 103 | Returns information about the default voice or sets a new default voice\. 104 | .br 105 | Note that any installed voice can be specified as the default voice, even 106 | .br 107 | if it is not among the set of active voices\. 108 | .IP \(bu 2 109 | 2nd synopsis form: \fB\.\.\.\fP 110 | .br 111 | Lists information about the specified voices (whether active or not)\. 112 | .IP \(bu 2 113 | 3rd synopsis form: \fB\-l [\.\.\.]\fP, \fB\-\-list [\.\.\.]\fP 114 | .br 115 | Lists information about active, installed, or voices matching one or more 116 | .br 117 | specified languages\. 118 | .br 119 | Lists all active voices by default; \fB\-a\fP lists all installed ones\. 120 | .br 121 | If at least one \fB\fP operand is given, the list of active voices (by 122 | .br 123 | default) / installed voices (with \fB\-a\fP) is filtered to output only those 124 | .br 125 | matching the specified language(s)\. 126 | \fB\fP values may be mere language IDs (e\.g\., \fBen\fP) or language + region 127 | .br 128 | IDs (e\.g\., \fBen_US\fP); e\.g\., \fBen\fP matches all English voices irrespective of 129 | .br 130 | region, whereas \fBen_US\fP matches only US English voices\. 131 | .IP \(bu 2 132 | 4th synopsis form: \fB\-L\fP, \fB\-\-list\-langs\fP 133 | .br 134 | Lists the distinct set of languages supported among all active (by default) 135 | .br 136 | or all installed (\fB\-a\fP) voices\. 137 | .br 138 | Languages are listed as language + region identifiers, e\.g\., \fBen_US\fP\|\. 139 | .IP \(bu 2 140 | 5th synopsis form: \fB\-m\fP, \fB\-\-manage\fP 141 | .br 142 | Opens System Preferences > Dictation & Speech, where you can manage the 143 | .br 144 | set of active voices, install additional voices, and control other aspects 145 | .br 146 | of text\-to\-speech synthesis\. 147 | 148 | .RE 149 | .SH SHARED OPTIONS 150 | .P 151 | These options complement the main options, which determine the synopsis form, 152 | .br 153 | discussed above\. 154 | .SS General Options 155 | .RS 0 156 | .IP \(bu 2 157 | \fB\-q\fP 158 | .br 159 | Quiet mode: suppresses printed output, such as when only speech output 160 | .br 161 | (\fB\-k\fP) is desired or when the new default voice should be set quietly\. 162 | .br 163 | Cannot be combined with \fB\-L\fP, whose sole purpose is to print 164 | .br 165 | information\. 166 | 167 | .RE 168 | .SS Speaking options (synopsis forms 1\-3): 169 | .P 170 | Note that if the command targets multiple voices, speaking happens 171 | after each voice's information has been printed (unless printing is 172 | .br 173 | suppressed with \fB\-q\fP)\. 174 | .RS 0 175 | .IP \(bu 2 176 | \fB\-k\fP, \fB\-\-speak\fP (no argument) 177 | .br 178 | Speaks each targeted voice's demo text\. 179 | .IP \(bu 2 180 | \fB\-k""\fP, \fB\-\-speak=""\fP 181 | .br 182 | Speaks the specified text using each targeted voice\. 183 | .br 184 | Note that \fB""\fP must be directly attached to the option and should 185 | .br 186 | generally be quoted to protect it from (unwanted) interpretation by the 187 | .br 188 | shell\. 189 | .IP \(bu 2 190 | \fB\-k\-\fP, \fB\-\-speak=\-\fP 191 | .br 192 | Speaks text provided via stdin using each targeted voice\. 193 | 194 | .RE 195 | .SS Printed\-Output Options (synopsis forms 1\-3) 196 | .P 197 | By default, voice information printed is in the form provided by the standard 198 | .br 199 | \fBsay\fP utility when invoked as \fBsay \-v \\?\fP, which is: 200 | .br 201 | \fB # \fP 202 | .P 203 | The following, mutually exclusive options modify this behavior: 204 | .RS 0 205 | .IP \(bu 2 206 | \fB\-b\fP, \fB\-\-bare\fP 207 | .br 208 | Outputs mere voice names only\. 209 | .IP \(bu 2 210 | \fB\-i\fP, \fB\-\-internals\fP 211 | .br 212 | Outputs internal voice identifiers, as used by the system\. 213 | 214 | .RE 215 | .SH STANDARD OPTIONS 216 | .P 217 | All standard options must be provided as the only argument; all of them provide 218 | .br 219 | information only\. 220 | .RS 0 221 | .IP \(bu 2 222 | \fB\-h, \-\-help\fP 223 | .br 224 | Prints the contents of the synopsis chapter to stdout for quick reference\. 225 | .IP \(bu 2 226 | \fB\-\-man\fP 227 | .br 228 | Displays this manual page, which is a helpful alternative to using \fBman\fP, 229 | .br 230 | if the manual page isn't installed\. 231 | .IP \(bu 2 232 | \fB\-\-version\fP 233 | .br 234 | Prints version information\. 235 | .IP \(bu 2 236 | \fB\-\-home\fP 237 | .br 238 | Opens this utility's home page in the system's default web browser\. 239 | 240 | .RE 241 | .SH LICENSE 242 | .P 243 | For license information, bug reports, and more, visit this utility's home page 244 | .br 245 | by running \fBvoices \-\-home\fP 246 | .SH EXAMPLES 247 | .P 248 | .RS 2 249 | .nf 250 | # List all active voices; add \-a to list all installed ones\. 251 | voices \-l 252 | 253 | # Print information about the default voice and speak its demo text\. 254 | voices \-d \-k 255 | 256 | # Print information about voice 'Alex'\. 257 | voices alex 258 | 259 | # Make 'Alex' the new default voice, print information about it, and 260 | # speak text that announces the change\. 261 | voices \-k'The new default voice is Alex\.' \-d alex 262 | 263 | # List languages for which at least one voice is active\. 264 | voices \-L 265 | 266 | # List active French voices\. 267 | voices \-l fr 268 | 269 | # Speak the respective demo text with all active voices\. 270 | voices \-l \-k 271 | 272 | # Speak "hello" first with Alex, then with Jill, suppressing printed 273 | # output\. 274 | voices \-k"hello" \-q alex jill 275 | 276 | # Print information about all active Spanish voices and speak their 277 | # respective demo text\. 278 | voices \-k \-l es 279 | .fi 280 | .RE 281 | 282 | -------------------------------------------------------------------------------- /osx-service/README.md: -------------------------------------------------------------------------------- 1 | Contains an OSX Automator-based services: 2 | 3 | * `Switch Default Voice.workflow`: for switching the default voice 4 | between designated voices, using an embedded `voices` instance. 5 | 6 | * `Speak With Specific Voice.workflow`: for speaking selected text with a specific voice, 7 | with support for stopping ongoing playback when invoked again; custom speaking 8 | rates are honored. 9 | 10 | 11 | A Git pre-commit hook (`.git/hooks/pre-commit`) does the following: 12 | 13 | * `*.workflow` folders with a 'Contents/net.same2u' subdir only: copies the latest `voices` CLI from `bin` there. 14 | * Removes any embedded QuickLook previews (*.png), as they're not needed (and quite large). 15 | * zips up the bundle for simple download from GitHub 16 | * adds the modified files to the index 17 | 18 | CAVEAT: Do NOT install a service by directly opening the `*.workflow` 19 | bundle here, as the system will then *move* rather than copy the 20 | bundle to `~/Library/Services`. 21 | 22 | -------------------------------------------------------------------------------- /osx-service/Speak With Specific Voice.workflow.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mklement0/voices/d90ad1ef3618b521c6c1acf1b5c59366425ddfc2/osx-service/Speak With Specific Voice.workflow.zip -------------------------------------------------------------------------------- /osx-service/Speak With Specific Voice.workflow/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSServices 6 | 7 | 8 | NSMenuItem 9 | 10 | default 11 | Speak With Specific Voice 12 | 13 | NSMessage 14 | runWorkflowAsService 15 | NSSendTypes 16 | 17 | public.utf8-plain-text 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /osx-service/Speak With Specific Voice.workflow/Contents/document.wflow: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AMApplicationBuild 6 | 409.2 7 | AMApplicationVersion 8 | 2.5 9 | AMDocumentVersion 10 | 2 11 | actions 12 | 13 | 14 | action 15 | 16 | AMAccepts 17 | 18 | Container 19 | List 20 | Optional 21 | 22 | Types 23 | 24 | com.apple.cocoa.string 25 | 26 | 27 | AMActionVersion 28 | 2.0.3 29 | AMApplication 30 | 31 | Automator 32 | 33 | AMParameterProperties 34 | 35 | COMMAND_STRING 36 | 37 | CheckedForUserDefaultShell 38 | 39 | inputMethod 40 | 41 | shell 42 | 43 | source 44 | 45 | 46 | AMProvides 47 | 48 | Container 49 | List 50 | Types 51 | 52 | com.apple.cocoa.string 53 | 54 | 55 | ActionBundlePath 56 | /System/Library/Automator/Run Shell Script.action 57 | ActionName 58 | Run Shell Script 59 | ActionParameters 60 | 61 | COMMAND_STRING 62 | # ------- BEGIN: CUSTOMIZE 63 | # Specify the voice to speak selected text with, as it appears in System Preferences. 64 | # Note: There must be no spaces around the "=". 65 | voice=Vicki 66 | # ------- END: CUSTOMIZE 67 | 68 | # If `say` is running, we assume that a previous invocation is still speaking 69 | # and speaking should be *stopped*. 70 | # Caveat: This will only work if either the original app from which speaking was initiated is still 71 | # frontmost with text selected, or, coincidentally, a now different frontmost app also has 72 | # text selected. Otherwise, this service won't be active. 73 | pgrep -x say && { pkill -x say; exit; } 74 | 75 | # ---- BEGIN: Helper functions 76 | 77 | # See also: getVoiceInternals() 78 | getLegacyVoiceInternals() { 79 | 80 | local internalVoiceName=${1// /} 81 | 82 | # --- Begin: list of numeric creator and voice IDs for *legacy* voices. 83 | # Note: Obtained by systematically making each legacy voice that is preinstalled on a US-English OS X 10.8.3 the default voice 84 | # and then examining ~/Library/Preferences/com.apple.speech.voice.prefs.plist 85 | # Legacy voices are those that do not have VoiceAttributes/VoiceSynthesizerNumericID and VoiceAttributes:VoiceNumericID keys in their 86 | # respective /System/Library/Speech/Voices/${voiceNameNoSpaces}.SpeechVoice/Contents/Info.plist files. 87 | # !! There is 1 EXCEPTION: The voice that System Preferences and its preferences file call "Pipe Organ" is just named 88 | # !! "Organ" in the actual voice bundle's path and Info.plist file. 89 | VoiceCreator_Agnes=1734437985 90 | VoiceID_Agnes=300 91 | VoiceCreator_Albert=1836346163 92 | VoiceID_Albert=41 93 | VoiceCreator_Alex=1835364215 94 | VoiceID_Alex=201 95 | VoiceCreator_BadNews=1836346163 96 | VoiceID_BadNews=36 97 | VoiceCreator_Bahh=1836346163 98 | VoiceID_Bahh=40 99 | VoiceCreator_Bells=1836346163 100 | VoiceID_Bells=26 101 | VoiceCreator_Boing=1836346163 102 | VoiceID_Boing=16 103 | VoiceCreator_Bruce=1734437985 104 | VoiceID_Bruce=100 105 | VoiceCreator_Bubbles=1836346163 106 | VoiceID_Bubbles=50 107 | VoiceCreator_Cellos=1836346163 108 | VoiceID_Cellos=35 109 | VoiceCreator_Deranged=1836346163 110 | VoiceID_Deranged=38 111 | VoiceCreator_Fred=1836346163 112 | VoiceID_Fred=1 113 | VoiceCreator_GoodNews=1836346163 114 | VoiceID_GoodNews=39 115 | VoiceCreator_Hysterical=1836346163 116 | VoiceID_Hysterical=30 117 | VoiceCreator_Junior=1836346163 118 | VoiceID_Junior=4 119 | VoiceCreator_Kathy=1836346163 120 | VoiceID_Kathy=2 121 | VoiceCreator_Organ=1836346163 # !! Shows up as "*Pipe *Organ" in System Preferences and preferences file. 122 | VoiceID_Organ=31 123 | VoiceCreator_Princess=1836346163 124 | VoiceID_Princess=3 125 | VoiceCreator_Ralph=1836346163 126 | VoiceID_Ralph=5 127 | VoiceCreator_Trinoids=1836346163 128 | VoiceID_Trinoids=9 129 | VoiceCreator_Vicki=1835364215 130 | VoiceID_Vicki=200 131 | VoiceCreator_Victoria=1734437985 132 | VoiceID_Victoria=200 133 | VoiceCreator_Whisper=1836346163 134 | VoiceID_Whisper=6 135 | VoiceCreator_Zarvox=1836346163 136 | VoiceID_Zarvox=8 137 | # --- End: list of numeric creator and voiced IDs for *legacy* voices 138 | 139 | vName_VoiceCreator="VoiceCreator_$internalVoiceName" 140 | vName_VoiceID="VoiceID_$internalVoiceName" 141 | 142 | VoiceCreator=${!vName_VoiceCreator} 143 | VoiceID=${!vName_VoiceID} 144 | 145 | } 146 | 147 | # Determines the internal identifiers of a voice, given as its friendly name, 148 | # as (partially) needed to set a given voice as the default voice. 149 | # Sets the following script-global variables: 150 | # InternalVoiceName 151 | # VoiceCreator 152 | # VoiceID 153 | getVoiceInternals() { 154 | 155 | local friendlyVoiceName=$1 plistFile 156 | 157 | # Locate the voice-specific Info.plist file (as of OS X 10.8.3) 158 | # Note: Some voice names have embedded spaces, but their corresponding folder names have the spaces removed. 159 | # !! We assume a case-insensitive filesystem. 160 | plistFile="/System/Library/Speech/Voices/${friendlyVoiceName// /}.SpeechVoice/Contents/Info.plist" 161 | # !! As of at least 10.10, there are compressed variants that have root folder-name suffix 'Compact'. 162 | # !! These are lower-quality versions with smaller footprint; we use them only if the higher-quality ones aren't available. 163 | [[ ! -f $plistFile ]] && plistFile="/System/Library/Speech/Voices/${friendlyVoiceName// /}Compact.SpeechVoice/Contents/Info.plist" 164 | 165 | if [[ ! -f $plistFile ]]; then 166 | # !! There is 1 EXCEPTION to the voice-name-to-filename mapping: "Pipe Organ" doesn't become "PipeOrgan", but just "Organ". 167 | grep -Ei "^pipeorgan$" <<<"${friendlyVoiceName// /}" &> /dev/null && plistFile="/System/Library/Speech/Voices/Organ.SpeechVoice/Contents/Info.plist" 168 | # If (ultimately) not found, abort. 169 | [[ -f $plistFile ]] || { echo "'$friendlyVoiceName' is not an installed voice." >&2; return 1; } 170 | fi 171 | 172 | # Determine the relevant IDs we need to switch the default voice. 173 | # Note: We're setting *script-global* variables here. 174 | InternalVoiceName=$(/usr/libexec/PlistBuddy -c "print :CFBundleName" $plistFile) || { echo "Voice '$friendlyVoiceName': failed to obtain internal voice name." >&2; return 1; } 175 | # !! For *compact* voices, $InternalVoiceNames will have suffix 'Compact', which we remove here, because 176 | # !! this suffix shows up nowhere else. 177 | # !! Key CFBundleName contains the same value as key VoiceName; however, only recent voices have the latter. 178 | # !! Similarly, only recent voices have key VoiceNameRoot, which, in the case of compact voices, also contains the voice name with suffix 'Compact' removed. 179 | InternalVoiceName=${InternalVoiceName%Compact} 180 | 181 | VoiceCreator=$(/usr/libexec/PlistBuddy -c "print :VoiceAttributes:VoiceSynthesizerNumericID" "$plistFile" 2>/dev/null) 182 | if [[ $? -ne 0 ]]; then # Must be a *legacy* voice - we take VoiceCreator and VoiceID from a hard-coded list. 183 | getLegacyVoiceInternals "$InternalVoiceName" 184 | [[ -n $VoiceCreator && -n $VoiceID ]] || { echo "Voice '$friendlyVoiceName': failed to obtain numeric creator and/or voice IDs." >&2; return 1; } 185 | else 186 | VoiceID=$(/usr/libexec/PlistBuddy -c "print :VoiceAttributes:VoiceNumericID" "$plistFile" 2>/dev/null) || { echo "Voice '$friendlyVoiceName': failed to obtain numeric voice ID." >&2; return 1; } 187 | fi 188 | 189 | } 190 | # ---- END: Helper functions 191 | 192 | # ---- BEGIN: Support for custom speaking rates configured via System Preferences 193 | 194 | # Sadly, as of OSX 10.11, `say` doesn't respect custom speaking rates when used with an explicit voice name (-v), so 195 | # we have to extract the custom rates ourselves and specify them explicitly with -r. 196 | 197 | rateOpts=() 198 | 199 | # Get all custom speaking rates (words per minute) from the preferences file - on a pristine system, not even the file may exist, let alone custom rates). 200 | # Strip all chars. so that only (voice-creator, voice-ID, custom-rate) triplets of lines remain; e.g.: 201 | # 1886745202 # voice creator 202 | # 184844493 # voice ID 203 | # 200 # speaking rate; a value *roughly* >= 90 <= 360 - in practice I've seen at least slightly lower (87) 204 | customRates=$(defaults read com.apple.speech.voice.prefs VoiceRateDataArray | tr -d '() ,' 2>/dev/null | sed '/^$/d' ) 205 | 206 | if [[ -n $customRates ]]; then 207 | 208 | # Get internal identifiers for the target voice. 209 | getVoiceInternals "$voice" 210 | 211 | # Extract the custom speaking rate for the target voice, if any. 212 | customRate=$(awk -v first="$VoiceCreator" -v second="$VoiceID" '$1 == second && prev == first { getline; print $1; exit } { prev = $1 }' <<<"$customRates") 213 | 214 | (( customRate > 0 )) && rateOpts=( -r "$customRate" ) 215 | 216 | fi 217 | # ---- END: Support for custom speaking rates 218 | 219 | # Read the text to speak into a variable. 220 | txt=$(</dev/stdin) 221 | 222 | # Speak, using the standard `say` CLI. 223 | say -v "$voice" "${rateOpts[@]}" "$txt" 224 | 225 | CheckedForUserDefaultShell 226 | 227 | inputMethod 228 | 0 229 | shell 230 | /bin/bash 231 | source 232 | 233 | 234 | BundleIdentifier 235 | com.apple.RunShellScript 236 | CFBundleVersion 237 | 2.0.3 238 | CanShowSelectedItemsWhenRun 239 | 240 | CanShowWhenRun 241 | 242 | Category 243 | 244 | AMCategoryUtilities 245 | 246 | Class Name 247 | RunShellScriptAction 248 | InputUUID 249 | 90521F3E-53A1-4C9C-8091-34CA970A8EB8 250 | Keywords 251 | 252 | Shell 253 | Script 254 | Command 255 | Run 256 | Unix 257 | 258 | OutputUUID 259 | 3276CD3A-C5C8-4EE5-A9EF-714DE43A53B4 260 | UUID 261 | FCC39CF7-1152-4AB7-BC81-7A8F1CE617FE 262 | UnlocalizedApplications 263 | 264 | Automator 265 | 266 | arguments 267 | 268 | 0 269 | 270 | default value 271 | 0 272 | name 273 | inputMethod 274 | required 275 | 0 276 | type 277 | 0 278 | uuid 279 | 0 280 | 281 | 1 282 | 283 | default value 284 | 285 | name 286 | source 287 | required 288 | 0 289 | type 290 | 0 291 | uuid 292 | 1 293 | 294 | 2 295 | 296 | default value 297 | 298 | name 299 | CheckedForUserDefaultShell 300 | required 301 | 0 302 | type 303 | 0 304 | uuid 305 | 2 306 | 307 | 3 308 | 309 | default value 310 | 311 | name 312 | COMMAND_STRING 313 | required 314 | 0 315 | type 316 | 0 317 | uuid 318 | 3 319 | 320 | 4 321 | 322 | default value 323 | /bin/sh 324 | name 325 | shell 326 | required 327 | 0 328 | type 329 | 0 330 | uuid 331 | 4 332 | 333 | 334 | conversionLabel 335 | 0 336 | isViewVisible 337 | 338 | location 339 | 781.000000:1128.000000 340 | nibPath 341 | /System/Library/Automator/Run Shell Script.action/Contents/Resources/English.lproj/main.nib 342 | 343 | isViewVisible 344 | 345 | 346 | 347 | connectors 348 | 349 | workflowMetaData 350 | 351 | serviceInputTypeIdentifier 352 | com.apple.Automator.text 353 | serviceOutputTypeIdentifier 354 | com.apple.Automator.nothing 355 | serviceProcessesInput 356 | 0 357 | workflowTypeIdentifier 358 | com.apple.Automator.servicesMenu 359 | 360 | 361 | 362 | -------------------------------------------------------------------------------- /osx-service/Switch Default Voice.workflow.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mklement0/voices/d90ad1ef3618b521c6c1acf1b5c59366425ddfc2/osx-service/Switch Default Voice.workflow.zip -------------------------------------------------------------------------------- /osx-service/Switch Default Voice.workflow/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSServices 6 | 7 | 8 | NSMenuItem 9 | 10 | default 11 | Switch Default Voice 12 | 13 | NSMessage 14 | runWorkflowAsService 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /osx-service/Switch Default Voice.workflow/Contents/document.wflow: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AMApplicationBuild 6 | 409.2 7 | AMApplicationVersion 8 | 2.5 9 | AMDocumentVersion 10 | 2 11 | actions 12 | 13 | 14 | action 15 | 16 | AMAccepts 17 | 18 | Container 19 | List 20 | Optional 21 | 22 | Types 23 | 24 | com.apple.cocoa.string 25 | 26 | 27 | AMActionVersion 28 | 2.0.3 29 | AMApplication 30 | 31 | Automator 32 | 33 | AMParameterProperties 34 | 35 | COMMAND_STRING 36 | 37 | CheckedForUserDefaultShell 38 | 39 | inputMethod 40 | 41 | shell 42 | 43 | source 44 | 45 | 46 | AMProvides 47 | 48 | Container 49 | List 50 | Types 51 | 52 | com.apple.cocoa.string 53 | 54 | 55 | ActionBundlePath 56 | /System/Library/Automator/Run Shell Script.action 57 | ActionName 58 | Run Shell Script 59 | ActionParameters 60 | 61 | COMMAND_STRING 62 | #!/usr/bin/env bash 63 | 64 | # Home page: https://github.com/mklement0/voices#osx-service-for-switching-between-default-voices 65 | 66 | # The FULL PATH OF THIS SERVICE'S CONFIGURATION FILE. 67 | # On first invocation of this service, it will open in your default text editor. 68 | kCONFIG_FILE=~/.SwitchDefaultVoice-rc 69 | 70 | # This workflow's name, which MUST BE IN SYNC WITH ITS BUNDLE FOLDER'S NAME. 71 | # !! Sadly, we cannot determine the path of the running workflow: $PWD is the user's home folder, 72 | # !! and $0 == '-', and $BASH_SOURCE is empty altogether. 73 | kTHIS_NAME='Switch Default Voice' 74 | 75 | # ---- BEGIN: helper functions 76 | 77 | # Helper functions. Note that `say` must come *before* `display alert`, because the latter blocks until the dialog is confirmed. 78 | die() { local msg="ERROR: ${1-Aborting due to unexpected error}"; (( voiceFeedback )) && say "$msg"; osascript -e 'display alert "'"$msg"'" as critical'; exit 1; } 79 | alert() { local msg=$1; (( voiceFeedback )) && say "$msg"; osascript -e 'display alert "'"$msg"'" as informational'; } 80 | 81 | # SYNOPSIS 82 | # indexOf [-i] needle "${haystack[@]}" 83 | # *Via stdout*, returns the zero-based index of a string element in an array of strings or -1, if not found. 84 | # -i makes matching case-INsensitive. 85 | # To be safe, specify -- in place of options if you don't need any. 86 | # Additionally, the exit code indicates if the element was found or not. 87 | # EXAMPLES 88 | # a=('one' 'two' 'three') 89 | # ndx=$(indexOf 'two' "${a[@]}") # -> $ndx is now 1 90 | # ndx=$(indexOf -i 'TWO' "${a[@]}") # -> $ndx is now 1 91 | indexOf() ( # run in subshell to localize effect of shopt 92 | local opt printElem=0 caseInsensitive=0 OPTARG= OPTIND=1 93 | while getopts 'i' opt; do 94 | case "$opt" in 95 | '?') # unknown option 96 | return 2 97 | ;; 98 | i) 99 | shopt -s nocasematch 100 | ;; 101 | esac 102 | done 103 | shift $((OPTIND - 1)) # Skip the already-processed arguments (options). 104 | 105 | local i=0 el needle=$1; shift 106 | for el; do [[ "$el" == "$needle" ]] && { echo "$i"; return 0; }; ((++i)); done 107 | { echo '-1'; return 1; } 108 | ) 109 | 110 | # Outputs the specified voice's locale identifier; e.g., 'en_US' 111 | # Note that *legacy* voices have no locale identifier in their Info.plist files; it's probably safe to assume 'en_US'. 112 | getVoiceLocaleId() { 113 | # Transform the friendly voice name into the internal one, which in most cases requires no action at all 114 | # However, there are legacy voices whose names have embedded spaces, where the spaces must be removed, and 115 | # in one case the mapping is irregular: 'Pipe Organ' must be transformed to 'Organ'. 116 | local voiceName=${1// /} plistFile 117 | [[ $voiceName == 'PipeOrgan' ]] && voiceName='Organ' 118 | # Locate the voice-specific Info.plist file (as of OS X 10.8.3) 119 | # !! We assume a case-insensitive filesystem. 120 | plistFile="/System/Library/Speech/Voices/${voiceName}.SpeechVoice/Contents/Info.plist" 121 | # !! As of at least 10.10, there are compressed variants that have root folder-name suffix 'Compact'. 122 | # !! These are lower-quality versions with smaller footprint; we use them only if the higher-quality ones aren't available. 123 | [[ ! -f $plistFile ]] && plistFile="/System/Library/Speech/Voices/${voiceName}Compact.SpeechVoice/Contents/Info.plist" 124 | /usr/libexec/PlistBuddy "$plistFile" -c 'print VoiceAttributes:VoiceLocaleIdentifier' 125 | } 126 | 127 | # ---- END: helper functions 128 | 129 | # Make sure that we can locate this workflow among the installed ones. 130 | kTHIS_FULL_PATH= 131 | for parentDir in ~/Library/Services /Library/Services; do 132 | dir="$parentDir/$kTHIS_NAME.workflow" 133 | [[ -d $dir ]] && { kTHIS_FULL_PATH=$dir; break; } 134 | done 135 | [[ -n $kTHIS_FULL_PATH ]] || die "This service workflow is either not currently installed, or had its name changed from '$kTHIS_NAME'." 136 | 137 | # Determine the full path to the embedded `voices` CLI, which we manually copied there. 138 | voicesExe=$kTHIS_FULL_PATH/Contents/net.same2u/voices 139 | 140 | # Ensure existence of `voices`. 141 | [[ -f $voicesExe ]] || die "Required supporting utility not found: $voicesExe" 142 | # Make executable on demand, if necessary. 143 | [[ -x $voicesExe ]] || chmod +x "$voicesExe" || die "Failed to make supporting utility executable: $voicesExe" 144 | 145 | # Read config file. 146 | # If the settings file doesn't exist, create it now with defaults. 147 | if [[ ! -f $kCONFIG_FILE ]]; then 148 | 149 | # The default config file embedded in this service. 150 | configFileDefault=$kTHIS_FULL_PATH/Contents/net.same2u/.SwitchDefaultVoice-rc 151 | 152 | # Prompt for opening the configuration file in the default text editor. 153 | # If the user declines, we exit here (pressing Cancel makes AppleScript throw a runtime error that make osascript report a nonzero exit code). 154 | # Note that we prompt BEFORE copying the default config file to ~, because the default 155 | # file would otherwise be in place already next time and the prompt wouldn't show again. 156 | osascript -s s &>/dev/null <<EOF || exit 0 157 | display dialog "You need to configure this service by specifying which voices to switch between." & return & return & "IMPORTANT: Make a note of the configuration file's path in case you need to edit it again later:" & return & "$kCONFIG_FILE" & return & return & "Press OK to open the configuration file in your default text editor now." with icon note 158 | EOF 159 | 160 | # Copy the default config file to ~ 161 | cp "$configFileDefault" "$kCONFIG_FILE" || die "Failed to copy default configuration file to $kCONFIG_FILE" 162 | 163 | # Open config file in default text editor and exit. 164 | open -t "$kCONFIG_FILE"; exit 165 | fi 166 | 167 | # Initialize variables - but don't preset voices: we want to make sure that the config file contains valid settings. 168 | valid=1 voices=() confirmations= voiceFeedback=1 169 | # Simply *source* the config file. 170 | . "$kCONFIG_FILE" || valid=0 171 | numVoices=${#voices[@]} 172 | (( numVoices > 1 )) || valid=0 173 | (( valid )) || die "Config file $kCONFIG_FILE either contains a syntax error or is missing information. Please fix it, or delete it to have it recreated with defaults." 174 | 175 | # Determine current default voice. 176 | # !! This should only fail in ONE scenario: a pristine machine on which the default voice was never changed, where 177 | # !! /Users/jdoe/Library/Preferences/com.apple.speech.voice.prefs.plist therefore doesn't even exist or doesn't 178 | # !! reflect the default (no "SelectedVoiceName" entry). We simply ignore this, and hope that there's truly no other scenario where this fails. 179 | currentVoice=$(defaults read com.apple.speech.voice.prefs SelectedVoiceName 2>/dev/null) 180 | 181 | # -- Determine the next voice (cyclically) to switch to. 182 | shopt -s nocasematch # Ignore differences in case. 183 | 184 | # Determine index of the next voice. 185 | newNdx=$(( ($(indexOf "$currentVoice" "${voices[@]}") + 1) % numVoices )) 186 | 187 | # Determine new voice name ... 188 | newVoice=${voices[newNdx]} 189 | 190 | # ...and the confirmation message to speak. 191 | # In the absence of a confirmation message we simply speak the new voice name by default. 192 | confirmation=${confirmations[newNdx]} # look for specific array entry 193 | [[ -z $confirmation ]] && confirmation="${confirmations[0]}" # otherwise, apply scalar value to all voices, if defined. 194 | [[ -z $confirmation ]] && confirmation='$newLang' # by default, we speak the localized name of the new voice's language 195 | 196 | [[ $confirmation == *'$newVoice'* ]] && confirmation=${confirmation//\$newVoice/$newVoice} 197 | if [[ $confirmation == *'$newLang'* ]]; then # text contains localized name of the new language. 198 | localeId=$(getVoiceLocaleId "$newVoice") # get locale ID 199 | [[ -z $localeId ]] && localeId='en_US' # legacy voices have no locale identifier in their Info.plist files; we assume US English. 200 | newLang=${!localeId} # the locale ID should be the name of a variable defined via sourcing the config file, and that variable's value is the localized language name 201 | [[ -z $newLang ]] && newLang=$localeId # fallback: use the locale ID 202 | [[ -z $newLang ]] && newLang=$newVoice # ultimate fallback: use the voice name. 203 | confirmation=${confirmation//\$newLang/$newLang} 204 | fi 205 | 206 | # Make the change. 207 | "$voicesExe" -q -d "$newVoice" || die "Failed to change the default voice." 208 | 209 | # Speak the confirmation, if requested. 210 | # !! Note that we use `say` without -v, relying on the fact that the default voice has already been switched, which has the 211 | # !! added advantage of a custom speaking rate being honored (using -v, even with the default voice name, does not honor the custom speaking rate as of OSX 10.11). 212 | (( voiceFeedback )) && say "$confirmation" 213 | 214 | CheckedForUserDefaultShell 215 | 216 | inputMethod 217 | 0 218 | shell 219 | /bin/bash 220 | source 221 | 222 | 223 | BundleIdentifier 224 | com.apple.RunShellScript 225 | CFBundleVersion 226 | 2.0.3 227 | CanShowSelectedItemsWhenRun 228 | 229 | CanShowWhenRun 230 | 231 | Category 232 | 233 | AMCategoryUtilities 234 | 235 | Class Name 236 | RunShellScriptAction 237 | InputUUID 238 | AE15BBDA-7A42-4100-B9F8-D88A8A5C71A4 239 | Keywords 240 | 241 | Shell 242 | Script 243 | Command 244 | Run 245 | Unix 246 | 247 | OutputUUID 248 | 416456E6-6131-4C7C-BE23-84C3BEE9D90A 249 | UUID 250 | 2C6D6479-49C3-4048-961E-97B36B9BFE91 251 | UnlocalizedApplications 252 | 253 | Automator 254 | 255 | arguments 256 | 257 | 0 258 | 259 | default value 260 | 0 261 | name 262 | inputMethod 263 | required 264 | 0 265 | type 266 | 0 267 | uuid 268 | 0 269 | 270 | 1 271 | 272 | default value 273 | 274 | name 275 | source 276 | required 277 | 0 278 | type 279 | 0 280 | uuid 281 | 1 282 | 283 | 2 284 | 285 | default value 286 | 287 | name 288 | CheckedForUserDefaultShell 289 | required 290 | 0 291 | type 292 | 0 293 | uuid 294 | 2 295 | 296 | 3 297 | 298 | default value 299 | 300 | name 301 | COMMAND_STRING 302 | required 303 | 0 304 | type 305 | 0 306 | uuid 307 | 3 308 | 309 | 4 310 | 311 | default value 312 | /bin/sh 313 | name 314 | shell 315 | required 316 | 0 317 | type 318 | 0 319 | uuid 320 | 4 321 | 322 | 323 | isViewVisible 324 | 325 | location 326 | 781.000000:1296.000000 327 | nibPath 328 | /System/Library/Automator/Run Shell Script.action/Contents/Resources/English.lproj/main.nib 329 | 330 | isViewVisible 331 | 332 | 333 | 334 | connectors 335 | 336 | workflowMetaData 337 | 338 | serviceInputTypeIdentifier 339 | com.apple.Automator.nothing 340 | serviceOutputTypeIdentifier 341 | com.apple.Automator.nothing 342 | serviceProcessesInput 343 | 0 344 | workflowTypeIdentifier 345 | com.apple.Automator.servicesMenu 346 | 347 | 348 | 349 | -------------------------------------------------------------------------------- /osx-service/Switch Default Voice.workflow/Contents/net.same2u/.SwitchDefaultVoice-rc: -------------------------------------------------------------------------------- 1 | # NOTE: 2 | # CUSTOMIZE SETTING BELOW. 3 | # BE SURE TO RETAIN THE PARENTHESES AROUND VALUES, WHERE PRESENT, AND TO 4 | # HAVE NO SPACES AROUND "=". 5 | # - This file's path is ~/.SwitchDefaultVoice-rc 6 | # - This file is parsed by ~/Library/Services/Switch Default Voice.workflow using Bash. 7 | # - For more information, go to https://github.com/mklement0/voices#osx-service-for-switching-between-default-voices 8 | 9 | # LIST OF VOICES: 10 | # NOTE: This is the only setting you MUST customize. 11 | # The default voices to switch between - specify their names as they appear 12 | # in System Preferences > Dictation & Speech > Text to Speech. 13 | voices=( Alex Vicki Jill ) 14 | 15 | # -- OPTIONAL further customization below. 16 | 17 | # VOICE FEEDBACK SWITCH: 18 | # The default is to give spoken feedback after changing the voice and when 19 | # errors occur, which is generally helpful to know when the switch has 20 | # completed. 21 | # Remove the "#" from the beginning of the following line to disable spoken 22 | # feedback. 23 | #voiceFeedback=0 24 | 25 | # CONFIRMATION TEXT: 26 | # If voice feedback is on (see below), the confirmation text to speak when 27 | # a new voice has been switched to. 28 | # - Default: 29 | # The localized name of the new voice's language, e.g. 'English (U.S.)' 30 | # - If specified: 31 | # IMPORTANT: Enclose values in *single* quotes; use '\'' for embedded 32 | # single quotes. 33 | # You may specify a *single* value that is then used for all languages. 34 | # Otherwise, specify an *array* with a specific entry for each voice 35 | # specified above. 36 | # The following variables may be used in the text: 37 | # $newVoice ... the name of the voice just switched to 38 | # $newLang ... the localized name of the new voice's language. 39 | # Single-value example; remove initial '#' to activate. 40 | #confirmations='$newVoice' 41 | # Array example; remove initial '#' to activate. 42 | #confirmations=( 'Alex speaking' 'I am Vicki' 'Jill here' ) 43 | 44 | 45 | # -- SETTINGS BELOW THIS LINE NORMALLY NEED NO CUSTOMIZATION. 46 | # However, you may tweak or extend them, if needed. 47 | 48 | # Mapping of language tags to their localized names. 49 | # Note that this list covers more languages than Apple currently offers. 50 | # See https://www.evernote.com/l/AEUqQAA9UI1HeqXRPKbVVEz-jF5oZMciZB0. 51 | # Original localized names slightly tweaked: 52 | # - to shorten 'United States' and 'United Kingdom' to 'U.S.' and 'U.K.' 53 | # - to remove the country suffix from languages with obvious home countries, 54 | # such as German, French, and Spain. 55 | 56 | af_ZA="Afrikaans (Suid Afrika)" # Afrikaans (South Africa) 57 | am_ET="አማርኛ (ኢትዮጵያ)" # Amharic (Ethiopia) 58 | ar_AE="العربية (الإمارات العربية المتحدة)‏" # Arabic (U.A.E.)‎ 59 | ar_BH="العربية (البحرين)‏" # Arabic (Bahrain)‎ 60 | ar_DZ="العربية (الجزائر)‏" # Arabic (Algeria)‎ 61 | ar_EG="العربية (مصر)‏" # Arabic (Egypt)‎ 62 | ar_IQ="العربية (العراق)‏" # Arabic (Iraq)‎ 63 | ar_JO="العربية (الأردن)‏" # Arabic (Jordan)‎ 64 | ar_KW="العربية (الكويت)‏" # Arabic (Kuwait)‎ 65 | ar_LB="العربية (لبنان)‏" # Arabic (Lebanon)‎ 66 | ar_LY="العربية (ليبيا)‏" # Arabic (Libya)‎ 67 | ar_MA="العربية (المملكة المغربية)‏" # Arabic (Morocco)‎ 68 | ar_OM="العربية (عمان)‏" # Arabic (Oman)‎ 69 | ar_QA="العربية (قطر)‏" # Arabic (Qatar)‎ 70 | ar_SA="العربية (المملكة العربية السعودية)‏" # Arabic (Saudi Arabia)‎ 71 | ar_SY="العربية (سوريا)‏" # Arabic (Syria)‎ 72 | ar_TN="العربية (تونس)‏" # Arabic (Tunisia)‎ 73 | ar_YE="العربية (اليمن)‏" # Arabic (Yemen)‎ 74 | arn="Mapudungun" # Mapudungun 75 | arn_CL="Mapudungun (Chile)" # Mapudungun (Chile) 76 | as_IN="অসমীয়া (ভাৰত)" # Assamese (India) 77 | az_Cyrl="Азәрбајҹан дили" # Azeri (Cyrillic) 78 | az_Latn="Azərbaycan­ılı" # Azeri (Latin) 79 | ba_RU="Башҡорт (Россия)" # Bashkir (Russia) 80 | be_BY="Беларускі (Беларусь)" # Belarusian (Belarus) 81 | bg_BG="български (България)" # Bulgarian (Bulgaria) 82 | bn_BD="বাংলা (বাংলাদেশ)" # Bengali (Bangladesh) 83 | bn_IN="বাংলা (ভারত)" # Bengali (India) 84 | bo_CN="བོད་ཡིག (ཀྲུང་ཧྭ་མི་དམངས་སྤྱི་མཐུན་རྒྱལ་ཁབ།)" # Tibetan (PRC) 85 | br_FR="brezhoneg (Frañs)" # Breton (France) 86 | bs_Cyrl="босански (Ћирилица)" # Bosnian (Cyrillic) 87 | bs_Latn="bosanski (Latinica)" # Bosnian (Latin) 88 | ca_ES="català (català)" # Catalan (Catalan) 89 | co_FR="Corsu (France)" # Corsican (France) 90 | cs_CZ="čeština (Česká republika)" # Czech (Czech Republic) 91 | cy_GB="Cymraeg (y Deyrnas Unedig)" # Welsh (United Kingdom) 92 | da_DK="dansk (Danmark)" # Danish (Denmark) 93 | de_AT="Deutsch (Österreich)" # German (Austria) 94 | de_CH="Deutsch (Schweiz)" # German (Switzerland) 95 | de_DE="Deutsch" # German (Germany) 96 | de_LI="Deutsch (Liechtenstein)" # German (Liechtenstein) 97 | de_LU="Deutsch (Luxemburg)" # German (Luxembourg) 98 | dsb="dolnoserbšćina" # Lower Sorbian 99 | dsb_DE="dolnoserbšćina (Nimska)" # Lower Sorbian (Germany) 100 | dv_MV="ދިވެހިބަސް (ދިވެހި ރާއްޖެ)‏" # Divehi (Maldives)‎ 101 | el_GR="Ελληνικά (Ελλάδα)" # Greek (Greece) 102 | en_029="English (Caribbean)" # English (Caribbean) 103 | en_AU="English (Australia)" # English (Australia) 104 | en_BZ="English (Belize)" # English (Belize) 105 | en_CA="English (Canada)" # English (Canada) 106 | en_GB="English (U.K.)" # English (United Kingdom) 107 | en_IE="English (Ireland)" # English (Ireland) 108 | en_IN="English (India)" # English (India) 109 | en_JM="English (Jamaica)" # English (Jamaica) 110 | en_MY="English (Malaysia)" # English (Malaysia) 111 | en_NZ="English (New Zealand)" # English (New Zealand) 112 | en_PH="English (Philippines)" # English (Republic of the Philippines) 113 | en_SG="English (Singapore)" # English (Singapore) 114 | en_TT="English (Trinidad y Tobago)" # English (Trinidad and Tobago) 115 | en_US="English (U.S.)" # English (United States) 116 | en_ZA="English (South Africa)" # English (South Africa) 117 | en_ZW="English (Zimbabwe)" # English (Zimbabwe) 118 | es_AR="Español (Argentina)" # Spanish (Argentina) 119 | es_BO="Español (Bolivia)" # Spanish (Bolivia) 120 | es_CL="Español (Chile)" # Spanish (Chile) 121 | es_CO="Español (Colombia)" # Spanish (Colombia) 122 | es_CR="Español (Costa Rica)" # Spanish (Costa Rica) 123 | es_DO="Español (República Dominicana)" # Spanish (Dominican Republic) 124 | es_EC="Español (Ecuador)" # Spanish (Ecuador) 125 | es_ES="Español" # Spanish (Spain, International Sort) 126 | es_GT="Español (Guatemala)" # Spanish (Guatemala) 127 | es_HN="Español (Honduras)" # Spanish (Honduras) 128 | es_MX="Español (México)" # Spanish (Mexico) 129 | es_NI="Español (Nicaragua)" # Spanish (Nicaragua) 130 | es_PA="Español (Panamá)" # Spanish (Panama) 131 | es_PE="Español (Perú)" # Spanish (Peru) 132 | es_PR="Español (Puerto Rico)" # Spanish (Puerto Rico) 133 | es_PY="Español (Paraguay)" # Spanish (Paraguay) 134 | es_SV="Español (El Salvador)" # Spanish (El Salvador) 135 | es_US="Español (Estados Unidos)" # Spanish (United States) 136 | es_UY="Español (Uruguay)" # Spanish (Uruguay) 137 | es_VE="Español (Republica Bolivariana de Venezuela)" # Spanish (Venezuela) 138 | et_EE="eesti (Eesti)" # Estonian (Estonia) 139 | eu_ES="euskara (euskara)" # Basque (Basque) 140 | fa_IR="فارسى (ایران)‏" # Persian‎ 141 | fi_FI="suomi (Suomi)" # Finnish (Finland) 142 | fil="Filipino" # Filipino 143 | fil_PH="Filipino (Pilipinas)" # Filipino (Philippines) 144 | fo_FO="føroyskt (Føroyar)" # Faroese (Faroe Islands) 145 | fr_BE="français (Belgique)" # French (Belgium) 146 | fr_CA="français (Canada)" # French (Canada) 147 | fr_CH="français (Suisse)" # French (Switzerland) 148 | fr_FR="français" # French (France) 149 | fr_LU="français (Luxembourg)" # French (Luxembourg) 150 | fr_MC="français (Principauté de Monaco)" # French (Monaco) 151 | fy_NL="Frysk (Nederlân)" # Frisian (Netherlands) 152 | ga_IE="Gaeilge (Éire)" # Irish (Ireland) 153 | gd_GB="Gàidhlig (An Rìoghachd Aonaichte)" # Scottish Gaelic (United Kingdom) 154 | gl_ES="galego (galego)" # Galician (Galician) 155 | gsw="Elsässisch" # Alsatian 156 | gsw_FR="Elsässisch (Frànkrisch)" # Alsatian (France) 157 | gu_IN="ગુજરાતી (ભારત)" # Gujarati (India) 158 | ha_Latn="Hausa (Latin)" # Hausa (Latin) 159 | he_IL="עברית (ישראל)‏" # Hebrew (Israel)‎ 160 | hi_IN="हिंदी (भारत)" # Hindi (India) 161 | hr_BA="hrvatski (Bosna i Hercegovina)" # Croatian (Latin, Bosnia and Herzegovina) 162 | hr_HR="hrvatski (Hrvatska)" # Croatian (Croatia) 163 | hsb="hornjoserbšćina" # Upper Sorbian 164 | hsb_DE="hornjoserbšćina (Němska)" # Upper Sorbian (Germany) 165 | hu_HU="magyar (Magyarország)" # Hungarian (Hungary) 166 | hy_AM="Հայերեն (Հայաստան)" # Armenian (Armenia) 167 | id_ID="Bahasa Indonesia (Indonesia)" # Indonesian (Indonesia) 168 | ig_NG="Igbo (Nigeria)" # Igbo (Nigeria) 169 | ii_CN="ꆈꌠꁱꂷ (ꍏꉸꏓꂱꇭꉼꇩ)" # Yi (PRC) 170 | is_IS="íslenska (Ísland)" # Icelandic (Iceland) 171 | it_CH="italiano (Svizzera)" # Italian (Switzerland) 172 | it_IT="italiano (Italia)" # Italian (Italy) 173 | iu_Cans="ᐃᓄᒃᑎᑐᑦ (ᖃᓂᐅᔮᖅᐸᐃᑦ)" # Inuktitut (Syllabics) 174 | iu_Latn="Inuktitut (Qaliujaaqpait)" # Inuktitut (Latin) 175 | ja_JP="日本語 (日本)" # Japanese (Japan) 176 | ka_GE="ქართული (საქართველო)" # Georgian (Georgia) 177 | kk_KZ="Қазақ (Қазақстан)" # Kazakh (Kazakhstan) 178 | kl_GL="kalaallisut (Kalaallit Nunaat)" # Greenlandic (Greenland) 179 | km_KH="ខ្មែរ (កម្ពុជា)" # Khmer (Cambodia) 180 | kn_IN="ಕನ್ನಡ (ಭಾರತ)" # Kannada (India) 181 | ko_KR="한국어 (대한민국)" # Korean (Korea) 182 | kok="कोंकणी" # Konkani 183 | kok_IN="कोंकणी (भारत)" # Konkani (India) 184 | ky_KG="Кыргыз (Кыргызстан)" # Kyrgyz (Kyrgyzstan) 185 | lb_LU="Lëtzebuergesch (Luxembourg)" # Luxembourgish (Luxembourg) 186 | lo_LA="ລາວ (ສ.ປ.ປ. ລາວ)" # Lao (Lao P.D.R.) 187 | lt_LT="lietuvių (Lietuva)" # Lithuanian (Lithuania) 188 | lv_LV="latviešu (Latvija)" # Latvian (Latvia) 189 | mi_NZ="Reo Māori (Aotearoa)" # Maori (New Zealand) 190 | mk_MK="македонски јазик (Македонија)" # Macedonian (Former Yugoslav Republic of Macedonia) 191 | ml_IN="മലയാളം (ഭാരതം)" # Malayalam (India) 192 | mn_Cyrl="Монгол хэл" # Mongolian (Cyrillic) 193 | mn_MN="Монгол хэл (Монгол улс)" # Mongolian (Cyrillic, Mongolia) 194 | mn_Mong="ᠮᠤᠨᠭᠭᠤᠯ ᠬᠡᠯᠡ" # Mongolian (Traditional Mongolian) 195 | moh="Kanien'kéha" # Mohawk 196 | moh_CA="Kanien'kéha" # Mohawk (Mohawk) 197 | mr_IN="मराठी (भारत)" # Marathi (India) 198 | ms_BN="Bahasa Melayu (Brunei Darussalam)" # Malay (Brunei Darussalam) 199 | ms_MY="Bahasa Melayu (Malaysia)" # Malay (Malaysia) 200 | mt_MT="Malti (Malta)" # Maltese (Malta) 201 | nb_NO="norsk, bokmål (Norge)" # Norwegian, Bokmål (Norway) 202 | ne_NP="नेपाली (नेपाल)" # Nepali (Nepal) 203 | nl_BE="Nederlands (België)" # Dutch (Belgium) 204 | nl_NL="Nederlands (Nederland)" # Dutch (Netherlands) 205 | nn_NO="norsk, nynorsk (Noreg)" # Norwegian, Nynorsk (Norway) 206 | nso="Sesotho sa Leboa" # Sesotho sa Leboa 207 | nso_ZA="Sesotho sa Leboa (Afrika Borwa)" # Sesotho sa Leboa (South Africa) 208 | oc_FR="Occitan (França)" # Occitan (France) 209 | or_IN="ଓଡ଼ିଆ (ଭାରତ)" # Oriya (India) 210 | pa_IN="ਪੰਜਾਬੀ (ਭਾਰਤ)" # Punjabi (India) 211 | pl_PL="polski (Polska)" # Polish (Poland) 212 | prs="درى‏" # Dari‎ 213 | prs_AF="درى (افغانستان)‏" # Dari (Afghanistan)‎ 214 | ps_AF="پښتو (افغانستان)‏" # Pashto (Afghanistan)‎ 215 | pt_BR="Português (Brasil)" # Portuguese (Brazil) 216 | pt_PT="português (Portugal)" # Portuguese (Portugal) 217 | qut="K'iche" # K'iche 218 | qut_GT="K'iche (Guatemala)" # K'iche (Guatemala) 219 | quz="runasimi" # Quechua 220 | quz_BO="runasimi (Qullasuyu)" # Quechua (Bolivia) 221 | quz_EC="runasimi (Ecuador)" # Quechua (Ecuador) 222 | quz_PE="runasimi (Piruw)" # Quechua (Peru) 223 | rm_CH="Rumantsch (Svizra)" # Romansh (Switzerland) 224 | ro_RO="română (România)" # Romanian (Romania) 225 | ru_RU="русский (Россия)" # Russian (Russia) 226 | rw_RW="Kinyarwanda (Rwanda)" # Kinyarwanda (Rwanda) 227 | sa_IN="संस्कृत (भारतम्)" # Sanskrit (India) 228 | sah="саха" # Yakut 229 | sah_RU="саха (Россия)" # Yakut (Russia) 230 | se_FI="davvisámegiella (Suopma)" # Sami, Northern (Finland) 231 | se_NO="davvisámegiella (Norga)" # Sami, Northern (Norway) 232 | se_SE="davvisámegiella (Ruoŧŧa)" # Sami, Northern (Sweden) 233 | si_LK="සිංහ (ශ්‍රී ලංකා)" # Sinhala (Sri Lanka) 234 | sk_SK="slovenčina (Slovenská republika)" # Slovak (Slovakia) 235 | sl_SI="slovenski (Slovenija)" # Slovenian (Slovenia) 236 | sma="åarjelsaemiengiele" # Sami (Southern) 237 | sma_NO="åarjelsaemiengiele (Nöörje)" # Sami, Southern (Norway) 238 | sma_SE="åarjelsaemiengiele (Sveerje)" # Sami, Southern (Sweden) 239 | smj="julevusámegiella" # Sami (Lule) 240 | smj_NO="julevusámegiella (Vuodna)" # Sami, Lule (Norway) 241 | smj_SE="julevusámegiella (Svierik)" # Sami, Lule (Sweden) 242 | smn="sämikielâ" # Sami (Inari) 243 | smn_FI="sämikielâ (Suomâ)" # Sami, Inari (Finland) 244 | sms="sääm´ǩiõll" # Sami (Skolt) 245 | sms_FI="sääm´ǩiõll (Lää´ddjânnam)" # Sami, Skolt (Finland) 246 | sq_AL="shqipe (Shqipëria)" # Albanian (Albania) 247 | sr_Cyrl="српски (Ћирилица)" # Serbian (Cyrillic) 248 | sr_Latn="srpski (Latinica)" # Serbian (Latin) 249 | sv_FI="svenska (Finland)" # Swedish (Finland) 250 | sv_SE="svenska (Sverige)" # Swedish (Sweden) 251 | sw_KE="Kiswahili (Kenya)" # Kiswahili (Kenya) 252 | syr="ܣܘܪܝܝܐ‏" # Syriac‎ 253 | syr_SY="ܣܘܪܝܝܐ (سوريا)‏" # Syriac (Syria)‎ 254 | ta_IN="தமிழ் (இந்தியா)" # Tamil (India) 255 | te_IN="తెలుగు (భారత దేశం)" # Telugu (India) 256 | tg_Cyrl="Тоҷикӣ" # Tajik (Cyrillic) 257 | th_TH="ไทย (ไทย)" # Thai (Thailand) 258 | tk_TM="türkmençe (Türkmenistan)" # Turkmen (Turkmenistan) 259 | tn_ZA="Setswana (Aforika Borwa)" # Setswana (South Africa) 260 | tr_TR="Türkçe (Türkiye)" # Turkish (Turkey) 261 | tt_RU="Татар (Россия)" # Tatar (Russia) 262 | tzm="Tamazight" # Tamazight 263 | tzm_Latn="Tamazight (Latin)" # Tamazight (Latin) 264 | tzm_Latn_DZ="Tamazight (Djazaïr)" # Tamazight (Latin, Algeria) 265 | ug_CN="(ئۇيغۇر يېزىقى (جۇڭخۇا خەلق جۇمھۇرىيىتى‏" # Uyghur (PRC)‎ 266 | uk_UA="українська (Україна)" # Ukrainian (Ukraine) 267 | ur_PK="اُردو (پاکستان)‏" # Urdu (Islamic Republic of Pakistan)‎ 268 | uz_Cyrl="Ўзбек" # Uzbek (Cyrillic) 269 | uz_Latn="U'zbek" # Uzbek (Latin) 270 | vi_VN="Tiếng Việt (Việt Nam)" # Vietnamese (Vietnam) 271 | wo_SN="Wolof (Sénégal)" # Wolof (Senegal) 272 | xh_ZA="isiXhosa (uMzantsi Afrika)" # isiXhosa (South Africa) 273 | yo_NG="Yoruba (Nigeria)" # Yoruba (Nigeria) 274 | zh_CN="中文(中华人民共和国)" # Chinese (Simplified, PRC) 275 | zh_HK="中文(香港特別行政區)" # Chinese (Traditional, Hong Kong S.A.R.) 276 | zh_Hans="中文(简体)" # Chinese (Simplified) 277 | zh_Hant="中文(繁體)" # Chinese (Traditional) 278 | zh_MO="中文(澳門特別行政區)" # Chinese (Traditional, Macao S.A.R.) 279 | zh_SG="中文(新加坡)" # Chinese (Simplified, Singapore) 280 | zh_TW="中文(台灣)" # Chinese (Traditional, Taiwan) 281 | zu_ZA="isiZulu (iNingizimu Afrika)" # isiZulu (South Africa) 282 | -------------------------------------------------------------------------------- /osx-service/Switch Default Voice.workflow/Contents/net.same2u/voices: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # --- STANDARD SCRIPT-GLOBAL CONSTANTS 4 | 5 | kTHIS_NAME=${BASH_SOURCE##*/} 6 | kTHIS_HOMEPAGE='https://github.com/mklement0/voices' 7 | kTHIS_VERSION='v0.3.4' # NOTE: This assignment is automatically updated by `make version VER=` - DO keep the 'v' prefix. 8 | 9 | unset CDPATH # Prevent unexpected `cd` behavior. 10 | PATH='/usr/bin:/bin:/usr/sbin:/sbin' # Use default $PATH to ensure that system versions of utilities are called. 11 | 12 | # --- Begin: STANDARD HELPER FUNCTIONS 13 | 14 | die() { echo "$kTHIS_NAME: ERROR: ${1:-"ABORTING due to unexpected error."}" 1>&2; exit ${2:-1}; } 15 | dieSyntax() { echo "$kTHIS_NAME: ARGUMENT ERROR: ${1:-"Invalid argument(s) specified."} Use -h for help." 1>&2; exit 2; } 16 | 17 | # SYNOPSIS 18 | # openUrl 19 | # DESCRIPTION 20 | # Opens the specified URL in the system's default browser. 21 | openUrl() { 22 | local url=$1 platform=$(uname) cmd=() 23 | case $platform in 24 | 'Darwin') # OSX 25 | cmd=( open "$url" ) 26 | ;; 27 | 'CYGWIN_'*) # Cygwin on Windows; must call cmd.exe with its `start` builtin 28 | cmd=( cmd.exe /c start '' "$url " ) # !! Note the required trailing space. 29 | ;; 30 | 'MINGW32_'*) # MSYS or Git Bash on Windows; they come with a Unix `start` binary 31 | cmd=( start '' "$url" ) 32 | ;; 33 | *) # Otherwise, assume a Freedesktop-compliant OS, which includes many Linux distros, PC-BSD, OpenSolaris, ... 34 | cmd=( xdg-open "$url" ) 35 | ;; 36 | esac 37 | "${cmd[@]}" || { echo "Cannot locate or failed to open default browser; please go to '$url' manually." >&2; return 1; } 38 | } 39 | 40 | # Prints the embedded Markdown-formatted man-page source to stdout. 41 | printManPageSource() { 42 | sed -n -e $'/^: <<\'EOF_MAN_PAGE\'/,/^EOF_MAN_PAGE/ { s///; t\np;}' "$BASH_SOURCE" 43 | } 44 | 45 | # Opens the man page, if installed; otherwise, tries to display the embedded Markdown-formatted man-page source; if all else fails: tries to display the man page online. 46 | openManPage() { 47 | local pager embeddedText 48 | if ! man 1 "$kTHIS_NAME" 2>/dev/null; then 49 | # 2nd attempt: if present, display the embedded Markdown-formatted man-page source 50 | embeddedText=$(printManPageSource) 51 | if [[ -n $embeddedText ]]; then 52 | pager='more' 53 | command -v less &>/dev/null && pager='less' # see if the non-standard `less` is available, because it's preferable to the POSIX utility `more` 54 | printf '%s\n' "$embeddedText" | "$pager" 55 | else # 3rd attempt: open the the man page on the utility's website 56 | openUrl "${kTHIS_HOMEPAGE}/doc/${kTHIS_NAME}.md" 57 | fi 58 | fi 59 | } 60 | 61 | # Prints the contents of the synopsis chapter of the embedded Markdown-formatted man-page source for quick reference. 62 | printUsage() { 63 | local embeddedText 64 | # Extract usage information from the SYNOPSIS chapter of the embedded Markdown-formatted man-page source. 65 | embeddedText=$(sed -n -e $'/^: <<\'EOF_MAN_PAGE\'/,/^EOF_MAN_PAGE/!d; /^## SYNOPSIS$/,/^#/{ s///; t\np; }' "$BASH_SOURCE") 66 | if [[ -n $embeddedText ]]; then 67 | # Print extracted synopsis chapter - remove backticks for uncluttered display. 68 | printf '%s\n\n' "$embeddedText" | tr -d '`' 69 | else # No SYNOPIS chapter found; fall back to displaying the man page. 70 | echo "WARNING: usage information not found; opening man page instead." >&2 71 | openManPage 72 | fi 73 | } 74 | 75 | # --- End: STANDARD HELPER FUNCTIONS 76 | 77 | # --- PROCESS STANDARD, OUTPUT-INFO-THEN-EXIT OPTIONS. 78 | case $1 in 79 | --version) 80 | # Output version number and exit, if requested. 81 | echo "$kTHIS_NAME $kTHIS_VERSION"$'\nFor license information and more, visit '"$kTHIS_HOMEPAGE"; exit 0 82 | ;; 83 | -h|--help) 84 | # Print usage information and exit. 85 | printUsage; exit 86 | ;; 87 | --man) 88 | # Display the manual page and exit, falling back to printing the embedded man-page source. 89 | openManPage; exit 90 | ;; 91 | --man-source) # private option, used by `make update-man` 92 | # Print raw, embedded Markdown-formatted man-page source and exit 93 | printManPageSource; exit 94 | ;; 95 | --home) 96 | # Open the home page and exit. 97 | openUrl "$kTHIS_HOMEPAGE"; exit 98 | ;; 99 | esac 100 | 101 | # ---- Begin: FUNCTIONS 102 | 103 | # See also: getVoiceInternals() 104 | getLegacyVoiceInternals() { 105 | 106 | local internalVoiceName=$1 107 | 108 | # --- Begin: list of numeric creator and voice IDs for *legacy* voices. 109 | # Note: Obtained by systematically making each legacy voice that is preinstalled on a US-English OS X 10.8.3 the default voice 110 | # and then examining ~/Library/Preferences/com.apple.speech.voice.prefs.plist 111 | # Legacy voices are those that do not have VoiceAttributes/VoiceSynthesizerNumericID and VoiceAttributes:VoiceNumericID keys in their 112 | # respective /System/Library/Speech/Voices/${voiceNameNoSpaces}.SpeechVoice/Contents/Info.plist files. 113 | # !! There is 1 EXCEPTION: The voice that System Preferences and its preferences file call "Pipe Organ" is just named 114 | # !! "Organ" in the actual voice bundle's path and Info.plist file. 115 | VoiceCreator_Agnes=1734437985 116 | VoiceID_Agnes=300 117 | VoiceCreator_Albert=1836346163 118 | VoiceID_Albert=41 119 | VoiceCreator_Alex=1835364215 120 | VoiceID_Alex=201 121 | VoiceCreator_BadNews=1836346163 122 | VoiceID_BadNews=36 123 | VoiceCreator_Bahh=1836346163 124 | VoiceID_Bahh=40 125 | VoiceCreator_Bells=1836346163 126 | VoiceID_Bells=26 127 | VoiceCreator_Boing=1836346163 128 | VoiceID_Boing=16 129 | VoiceCreator_Bruce=1734437985 130 | VoiceID_Bruce=100 131 | VoiceCreator_Bubbles=1836346163 132 | VoiceID_Bubbles=50 133 | VoiceCreator_Cellos=1836346163 134 | VoiceID_Cellos=35 135 | VoiceCreator_Deranged=1836346163 136 | VoiceID_Deranged=38 137 | VoiceCreator_Fred=1836346163 138 | VoiceID_Fred=1 139 | VoiceCreator_GoodNews=1836346163 140 | VoiceID_GoodNews=39 141 | VoiceCreator_Hysterical=1836346163 142 | VoiceID_Hysterical=30 143 | VoiceCreator_Junior=1836346163 144 | VoiceID_Junior=4 145 | VoiceCreator_Kathy=1836346163 146 | VoiceID_Kathy=2 147 | VoiceCreator_Organ=1836346163 # !! Shows up as "*Pipe *Organ" in System Preferences and preferences file. 148 | VoiceID_Organ=31 149 | VoiceCreator_Princess=1836346163 150 | VoiceID_Princess=3 151 | VoiceCreator_Ralph=1836346163 152 | VoiceID_Ralph=5 153 | VoiceCreator_Trinoids=1836346163 154 | VoiceID_Trinoids=9 155 | VoiceCreator_Vicki=1835364215 156 | VoiceID_Vicki=200 157 | VoiceCreator_Victoria=1734437985 158 | VoiceID_Victoria=200 159 | VoiceCreator_Whisper=1836346163 160 | VoiceID_Whisper=6 161 | VoiceCreator_Zarvox=1836346163 162 | VoiceID_Zarvox=8 163 | # --- End: list of numeric creator and voiced IDs for *legacy* voices 164 | 165 | vName_VoiceCreator="VoiceCreator_$internalVoiceName" 166 | vName_VoiceID="VoiceID_$internalVoiceName" 167 | 168 | VoiceCreator=${!vName_VoiceCreator} 169 | VoiceID=${!vName_VoiceID} 170 | 171 | } 172 | 173 | 174 | # Determines the internal identifiers of a voice, given as its friendly name, 175 | # as (partially) needed to set a given voice as the default voice. 176 | # *Sets* the following *script-global variables*: 177 | # InternalVoiceName 178 | # VoiceCreator 179 | # VoiceID 180 | # BundleID 181 | getVoiceInternals() { 182 | 183 | local friendlyVoiceName=$1 plistFile internalVoiceName 184 | 185 | # Get the internal voice name - note that this one may not be case-exact, 186 | # which is why we extract the exact case from the Info.plist file below 187 | # and store in global var. $InternalVoiceName (note the uppercase first letter). 188 | internalVoiceName=$(friendlyToInternalVoiceName "$friendlyVoiceName") 189 | 190 | # Locate the voice-specific Info.plist file (as of OS X 10.8.3) 191 | # !! We assume a case-insensitive filesystem. 192 | plistFile="/System/Library/Speech/Voices/${internalVoiceName}.SpeechVoice/Contents/Info.plist" 193 | # !! As of at least 10.10, there are compressed variants that have root folder-name suffix 'Compact'. 194 | # !! These are lower-quality versions with smaller footprint; we use them only if the higher-quality ones aren't available. 195 | [[ ! -f $plistFile ]] && plistFile="/System/Library/Speech/Voices/${internalVoiceName}Compact.SpeechVoice/Contents/Info.plist" 196 | 197 | # If (ultimately) not found, abort. 198 | [[ -f $plistFile ]] || die "'$friendlyVoiceName' is not an installed voice." 199 | 200 | # Determine the relevant IDs we need to switch the default voice. 201 | # Note: We're setting *script-global* variables here. 202 | InternalVoiceName=$(/usr/libexec/PlistBuddy -c "print :CFBundleName" $plistFile) || die "Voice '$friendlyVoiceName': failed to obtain internal voice name." 203 | # !! For *compact* voices, $InternalVoiceNames will have suffix 'Compact', which we remove here, because 204 | # !! this suffix shows up nowhere else. 205 | # !! Key CFBundleName contains the same value as key VoiceName; however, only recent voices have the latter. 206 | # !! Similarly, only recent voices have key VoiceNameRoot, which, in the case of compact voices, also contains the voice name with suffix 'Compact' removed. 207 | InternalVoiceName=${InternalVoiceName%Compact} 208 | 209 | VoiceCreator=$(/usr/libexec/PlistBuddy -c "print :VoiceAttributes:VoiceSynthesizerNumericID" "$plistFile" 2>/dev/null) 210 | if [[ $? -ne 0 ]]; then # Must be a *legacy* voice - we take VoiceCreator and VoiceID from a hard-coded list. 211 | getLegacyVoiceInternals "$InternalVoiceName" 212 | [[ -n $VoiceCreator && -n $VoiceID ]] || die "Voice '$friendlyVoiceName': failed to obtain numeric creator and/or voice IDs." 213 | else 214 | VoiceID=$(/usr/libexec/PlistBuddy -c "print :VoiceAttributes:VoiceNumericID" "$plistFile" 2>/dev/null) || die "Voice '$friendlyVoiceName': failed to obtain numeric voice ID." 215 | fi 216 | 217 | BundleID=$(/usr/libexec/PlistBuddy -c "print :CFBundleIdentifier" $plistFile) || die "Voice '$friendlyVoiceName': failed to obtain bundle ID." 218 | 219 | } 220 | 221 | 222 | 223 | # List all *installed* voices (whether active or not). 224 | # Returns the output from `say -v \?`. 225 | listInstalledVoices() { 226 | say -v \? || die "Failed to list installed voices." 227 | } 228 | 229 | 230 | # List all *active* voices (typically a *subset* of all installed voices, selected by the user for active use via System Preferenes > Dictation & Speech). 231 | # Returns filtered output from `say -v \?`. 232 | listActiveVoices() { 233 | listInstalledVoices | grep -Ei "$(printf '^%s \n' "$(getActiveVoiceNames)")" 234 | } 235 | 236 | # SYNOPSIS 237 | # getVoiceNamesByLangId [-a] langIdPrefix... 238 | # DESCRIPTION 239 | # Returns the friendly names of all active (by default) or installed (-a) voices. 240 | # whose language ID matches the specified language-ID prefixes (case-insensitively). 241 | getVoiceNamesByLangId() { 242 | local allInstalled=0 243 | [[ $1 == '-a' ]] && { allInstalled=1; shift; } 244 | # The output of listActiveVoices/listInstalledVoices - via `say -v \?` - is # 245 | # The difficulty is that friendlyVoiceName may contain embedded spaces, so we need to match accordingly. 246 | # On output we separate by $'\t' to simplify lang-ID matching and returning only the 1st field (the voice name). 247 | { (( allInstalled )) && listInstalledVoices || listActiveVoices; } | sed -E 's/^(.*[^ ]) +([^ #]+) +#/\1'$'\t''\2/' | grep -Ei "$(printf '\t%s.*\n' "$@")" | cut -d $'\t' -f1 248 | } 249 | 250 | # Prints the internal identifiers for the specified voice in the following form: 251 | # "InternalVoiceName= VoiceCreator= VoiceID=" 252 | printVoiceInternals() { 253 | getVoiceInternals "$1" 254 | local v result='' 255 | for v in InternalVoiceName VoiceCreator VoiceID BundleID; do 256 | [[ -z $result ]] && result="$v=${!v}" || result+=" $v=${!v}" 257 | done 258 | echo "$result" 259 | } 260 | 261 | # Outputs the friendly voice names of all *installed* voices, irrespective of whether the 262 | # user chose them for active use by placing a checkmark next to them in System Preferences > Dictation & Speech. 263 | getInstalledVoiceNames() { 264 | # say -v \? prints all installed voices with their friendly names in the 1st column; the challenge is that the may contain embedded spaces. 265 | say -v \? | sed -E 's/^(.*[^ ]) +([^ #]+) +#.*/\1/' 266 | } 267 | 268 | # Outputs the friendly voice names of those voices that are currently *active*. 269 | # Active voices are the *subset* of all *installed* voices that the user chose to actively work with 270 | # by placing a checkmark next to them in System Preferences > Dictation & Speech 271 | # (the ones that show up directly in the pop-up list - as opposed to the ones only visible when you choose 'Customize...' in that list). 272 | getActiveVoiceNames() { 273 | 274 | local FILE_PREFS="$HOME/Library/Preferences/com.apple.speech.voice.prefs.plist" 275 | # !! As of OS X 10.8.3: The list of voices that are *active by default* (and thus also preinstalled). 276 | local ACTIVE_BY_DEFAULT=$(cat <<'EOF' 277 | com.apple.speech.synthesis.voice.Alex 278 | com.apple.speech.synthesis.voice.Bruce 279 | com.apple.speech.synthesis.voice.Fred 280 | com.apple.speech.synthesis.voice.Kathy 281 | com.apple.speech.synthesis.voice.Vicki 282 | com.apple.speech.synthesis.voice.Victoria 283 | EOF 284 | ) 285 | local activeNonDefaults deactivatedDefaults activeDefaults active 286 | 287 | if [[ -f $FILE_PREFS ]]; then 288 | 289 | local re='^\s+com\.apple\.speech\.synthesis\.voice\.[^ ]+ = ' 290 | 291 | # Get all *explicitly activated* voices, *except those that are active *by default*. 292 | # These are voices that were explicitly selected by the user (and downloaded in the process.) 293 | # Note that we do NOT include voices from the set of those that are active by default (which also may show up with flag value 1 once their status has 294 | # been toggled by user action), as we deal with them later. 295 | activeNonDefaults=$(/usr/libexec/PlistBuddy -c 'print' "$FILE_PREFS" | grep -E "$re"'1$' | awk '{ print $1 }' | fgrep -xv "$ACTIVE_BY_DEFAULT") 296 | 297 | # Get the list of *explicitly deactivated* voices among the *active-by-default* ones. 298 | deactivatedDefaults=$(/usr/libexec/PlistBuddy -c 'print' "$FILE_PREFS" | grep -E "$re"'0$' | awk '{ print $1 }' | fgrep -x "$ACTIVE_BY_DEFAULT") 299 | 300 | 301 | # pv activeNonDefaults deactivatedDefaults 302 | 303 | if [[ -n $deactivatedDefaults ]]; then 304 | # Remove them from the list of active-by-default ones. 305 | # In effect: get the list of those active-by-default voices that are *currently* active. 306 | activeDefaults=$(echo "$ACTIVE_BY_DEFAULT" | fgrep -xv "$deactivatedDefaults") 307 | else 308 | activeDefaults=$ACTIVE_BY_DEFAULT 309 | fi 310 | 311 | # Now merge the activate non-defaults and the non-deactivated active-by-default ones 312 | # to yield the effective list of active voices: 313 | active=$activeDefaults 314 | [[ -n $active ]] && active+=$'\n' 315 | active+=$activeNonDefaults 316 | 317 | else 318 | # No prefs. file (pristine installation of OSX): use the defaults. 319 | active=$ACTIVE_BY_DEFAULT 320 | fi 321 | 322 | # Extract the internal names from the bundle IDs - note that premium voices have ".premium" as a suffix - 323 | # and output the friendly equivalents of the internal names. 324 | echo "$active" | awk -F '\\.' '{ sub(/\.premium$/, ""); print $NF }' | internalToFriendlyVoiceName 325 | } 326 | 327 | # SYNOPSIS 328 | # internalToFriendlyVoiceName [internalName...] 329 | # DESCRIPTION 330 | # Translates internal voice names to friendly voice names. 331 | # Internal names may be supplied as operands or via stdin (line by line). 332 | # Output is always line-based, with each friendly voice name output on its own line. 333 | # 334 | # Internal voice names occur in the following places: 335 | # - as part of bundle IDs stored in keys inside ~/Library/Preferences/com.apple.speech.voice.prefs.plist 336 | # - as folder names in /System/Library/Speech/Voices/{internalVoiceName}(Compact)?.SpeechVoice 337 | # - in these folders' ./Contents/Info.plist files as the values of CFBundleName/VoiceName/VoiceNameRoot keys 338 | # - VoiceNameRoot, if present contains the mere internal voice name (stripped of any 'Compact' suffix) 339 | # - VoiceName, if present, and CFBundleName do have the 'Compact' suffix for low-quality voices, if applicable. 340 | # - Legacy voices only have the CFBundleName key, without ever having suffix 'Compact'. 341 | # 342 | # Friendly voice names occur in the following places: 343 | # - in System Preferences, in the TTS (Dictation & Speech) and VoiceOver (Accessibility) settings 344 | # - in the output of `say -v \?` 345 | # - in the 'SelectedVoiceName' value of the com.apple.speech.voice.prefs preferences file. 346 | # 347 | # !! Translation is simplified based on the following assumptions: 348 | # !! A friendly name is the same as the internal name except for the following legacy novelty voices: 349 | # !! Organ -> 'Pipe Organ' 350 | # !! GoodNews -> 'Good News' 351 | # !! These mappings aren't stored explicitly anywhere I could discover; with 'GoodNews' one could suspect word separation 352 | # !! based on camel-case, but that doesn't apply to 'Organ'. 353 | # !! Note that we assume that friendly voice names are case-INsensitive so that extracting internal and ultimately friendly 354 | # !! names from bundle ID - which are typically all-lowercase - is acceptable. E.g., the assumption is that the system 355 | # !! treats 'anna' the same as 'Anna'. 356 | # !! Note that guessing the case based on capitalizing the initial letter and the 1st letter following a '-' would not work in all cases: 357 | # !! cf. 'Mei-Jia' (Tawain) and 'Sin-ji' (Hong Kong). To truly get the friendly name, it would have to be derived from 358 | # !! multiple keys in /System/Library/Speech/Voices/{internalVoiceName}(Compact)?.SpeechVoice/Contents/Info.plist. 359 | internalToFriendlyVoiceName() ( 360 | shopt -s nocasematch 361 | while read -r internalName; do 362 | case $internalName in 363 | organ) 364 | echo "Pipe Organ" 365 | ;; 366 | goodnews) 367 | echo "Good News" 368 | ;; 369 | *) 370 | echo "$internalName" 371 | ;; 372 | esac 373 | done < <( (( $# > 0 )) && printf '%s\n' "$@" || cat ) 374 | ) 375 | 376 | # Inverse of internalToFriendlyVoiceName() 377 | friendlyToInternalVoiceName() { 378 | 379 | # The internal voice name is generally just the friendly one with spaces removed. 380 | # Only 2 voices, which are legacy voices, have spaces in their friendly names: 'Good News' and 'Pipe Organ' 381 | # Presumably, no future voices will have embedded spaces. 382 | local internalVoiceName=${1// /} 383 | 384 | # There's one exception: friendly name 'Pipe Organ' maps to just 'Organ'. 385 | case $internalVoiceName in 386 | 'PipeOrgan') 387 | internalVoiceName='Organ' 388 | ;; 389 | esac 390 | 391 | printf '%s\n' "$internalVoiceName" 392 | 393 | } 394 | 395 | # Caches the custom speaking rates from com.apple.speech.voice.prefs for *this shell session only*. 396 | # in global variable 397 | # $customSpeakingRates 398 | # $customSpeakingRates is filled - once for this shell - as follows: 399 | # Get all custom speaking rates (words per minute) from the preferences file - on a pristine system, not even the file may exist, let alone custom rates). 400 | # Strip all chars. so that only (voice-creator, voice-ID, custom-rate) line triplets remain; e.g.: 401 | # 1886745202 # voice creator 402 | # 184844493 # voice ID 403 | # 200 # speaking rate; a value *roughly* >= 90 <= 360 404 | getCachedCustomSpeakingRates() { 405 | [[ -z $customSpeakingRates && -n ${customSpeakingRates-unset} ]] && customSpeakingRates=$(defaults read com.apple.speech.voice.prefs VoiceRateDataArray 2>/dev/null | tr -d '() ,' | sed '/^$/d' ) 406 | } 407 | 408 | # Outputs the custom speaking rate for the specified voice, if it is defined - range is *roughly* between 90 and 360 - apparently, it's possible to at least get slightly lower. 409 | # If not defined, outputs nothing. 410 | getCustomSpeakingRate() { 411 | local voice=$1 customRate 412 | 413 | # Set global variable $customSpeakingRates to contain any defined custom-speaking rates as 414 | # (voice-creator, voice-ID, custom-rate) line triplets. 415 | getCachedCustomSpeakingRates 416 | 417 | if [[ -n $customSpeakingRates ]]; then # short-circuit if there are no custom speaking rates at all 418 | 419 | # Get (cached) internal identifiers for the target voice. 420 | # NOTE: This is fairly time-consuming operation. 421 | # This sets global variables InternalVoiceName, VoiceCreator, VoiceID 422 | getVoiceInternals "$voice" 423 | 424 | # Extract the custom speaking rate, if any, for the target voice. 425 | customRate=$(awk -v first="$VoiceCreator" -v second="$VoiceID" '$1 == second && prev == first { getline; print $1; exit } { prev = $1 }' <<<"$customSpeakingRates") 426 | 427 | echo "$customRate" 428 | 429 | fi 430 | } 431 | 432 | 433 | # SYNOPSIS 434 | # speakText friendlyVoiceName [text] 435 | # If is missing or empty, the demo text is spoken. 436 | speakText() { 437 | local friendlyVoiceName=$1 text=$2 rateOpts 438 | 439 | if [[ -z $text ]]; then # No text specified? Use demo text. 440 | text=$(say -v \? | egrep -i "^$friendlyVoiceName +[a-z]{2}[_-]\w+ +#" | awk -F '#' '{ print $2; }') 441 | fi 442 | 443 | # !! Sadly, as of OSX 10.11, `say` doesn't respect custom speaking rates defined in System Preferences 444 | # !! when used with an explicit voice name (-v) (reported to Apple, 445 | # !! so we have to extract the custom rates ourselves and specify them explicitly with -r. 446 | # !! Should `say` ever become custom-rate aware, this will no longer be needed. 447 | rateOpts=() 448 | customRate=$(getCustomSpeakingRate "$friendlyVoiceName") 449 | (( customRate > 0 )) && rateOpts=( -r "$customRate" ) 450 | 451 | say -v "$friendlyVoiceName" "${rateOpts[@]}" -- "$text" 452 | } 453 | 454 | openTtsSystemPrefs() { 455 | osascript <<'EOF' 456 | set AppleScript's text item delimiters to "." 457 | set minorOsNum to text item 2 of system version of (system info) as number 458 | tell application "System Preferences" 459 | if minorOsNum ≥ 12 then # 10.12+ (Sierra+) 460 | reveal anchor "TextToSpeech" of pane "com.apple.preference.universalaccess" 461 | else # 10.11- (El Capitan-) 462 | reveal anchor "TTS" of pane "com.apple.preference.speech" 463 | end if 464 | activate 465 | end tell 466 | EOF 467 | } 468 | 469 | getDefaultVoiceName() { 470 | # Note: SelectedVoiceName actually contains the *friendly*, not the internal name. 471 | defaults read com.apple.speech.voice.prefs SelectedVoiceName 472 | } 473 | 474 | # setDefaultVoice friendlyVoiceName 475 | setDefaultVoice() { 476 | 477 | local friendlyVoiceName=$1 478 | 479 | # Determine the specified voice's internal identifiers. 480 | # Note that getVoiceInternals() sets shell-global variables $InternalVoiceName, $VoiceID, and $VoiceCreator. 481 | getVoiceInternals "$friendlyVoiceName" || return 482 | 483 | # Write the identifiers for the new default voice. 484 | defaults write com.apple.speech.voice.prefs 'SelectedVoiceCreator' -int $VoiceCreator || die 485 | defaults write com.apple.speech.voice.prefs 'SelectedVoiceID' -int $VoiceID || die 486 | # Note: SelectedVoiceName actually contains the *friendly*, not the internal name. Case does NOT matter. 487 | defaults write com.apple.speech.voice.prefs 'SelectedVoiceName' -string "$friendlyVoiceName" || die 488 | 489 | # Sadly, there's no official way to notify the system of a change in default voice, as the only official way 490 | # to change the default voice is to use System Preferences interactively. 491 | # Simply updating defaults is NOT enough for the text-to-speech feature to pick up the change 492 | # - only `say` does. 493 | # Without further action, text-to-speech would only pick up the change on next reboot or after logging out and back in. 494 | # An effective workaround is to kill the the per-user speech-synthesis server, which causes 495 | # the system to instantly restart it - at which point the new settings are read and take effect. 496 | # We keep our fingers crossed that the name and location of the speech-synthesis server, SpeechSynthesisServer.app, 497 | # does not change in future OSX versions. 498 | # The current name was obtained on OSX 10.10.3 as follows: 499 | # Activity Monitor > search for 'speech'. 500 | # Note that it is the speech-synthesis server *daemon*, com.apple.speech.speechsynthesisd, that has the current default voice open 501 | # if you inspect the Open Files and Ports tab. 502 | # It is tempting, to simply run pkill com.apple.speech.speechsynthesisd and let the system restart the process, but that does NOT 503 | # fully work: while changing the voice per se is effective, *custom speaking rates for the voices are NOT honored& - 504 | # whatever rate was last active lingers. 505 | # **Thus, it is the speech-synthesis *server* we must kill and manually restart.** 506 | # Tip of the hat to http://stackoverflow.com/a/27776019/45375 507 | pkill -x SpeechSynthesisServer &>/dev/null 508 | # The following path is the abstracted version - using the system-installed symlinks such as 'Current' that point to the active location - of: 509 | # /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/SpeechSynthesis.framework/Versions/A/SpeechSynthesisServer.app 510 | # The actual process command-line launched by the `open` command below looks like this: 511 | # /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/SpeechSynthesis.framework/Versions/A/SpeechSynthesisServer.app/Contents/MacOS/SpeechSynthesisServer launchd 512 | open /System/Library/Frameworks/ApplicationServices.framework/Frameworks/SpeechSynthesis.framework/Versions/Current/SpeechSynthesisServer.app || cat <&2 513 | WARNING: 514 | Failed to restart the speech-synthesis server. 515 | While the \`say\` utility will reflect the new default voice instantly, 516 | the text-to-speech feature may not use the new voice until after a reboot. 517 | EOF 518 | 519 | } 520 | 521 | # ---- End: FUNCTIONS 522 | 523 | 524 | # ---- MAIN BODY 525 | 526 | # ----- BEGIN: OPTIONS PARSING: This is MOSTLY generic code, but: 527 | # Option-parameters loop. 528 | default=0 529 | listLangs=0 530 | list=0 531 | allInstalled=0 532 | bare=0 533 | internals=0 534 | validateVoiceNames=0 535 | speak=0 536 | manage=0 537 | quiet=0 538 | text='' 539 | allowOptsAfterOperands=1 operands=() i=0 optName= isLong=0 prefix= optArg= haveOptArgAttached=0 haveOptArgAsNextArg=0 acceptOptArg=0 needOptArg=0 540 | while (( $# )); do 541 | if [[ $1 =~ ^(-)[a-zA-Z0-9]+.*$ || $1 =~ ^(--)[a-zA-Z0-9]+.*$ ]]; then # an option: either a short option / multiple short options in compressed form or a long option 542 | prefix=${BASH_REMATCH[1]}; [[ $prefix == '--' ]] && isLong=1 || isLong=0 543 | for (( i = 1; i < (isLong ? 2 : ${#1}); i++ )); do 544 | acceptOptArg=0 needOptArg=0 haveOptArgAttached=0 haveOptArgAsNextArg=0 optArgAttached= optArgOpt= optArgReq= 545 | if (( isLong )); then # long option: parse into name and, if present, argument 546 | optName=${1:2} 547 | [[ $optName =~ ^([^=]+)=(.*)$ ]] && { optName=${BASH_REMATCH[1]}; optArgAttached=${BASH_REMATCH[2]}; haveOptArgAttached=1; } 548 | else # short option: *if* it takes an argument, the rest of the string, if any, is by definition the argument. 549 | optName=${1:i:1}; optArgAttached=${1:i+1}; (( ${#optArgAttached} >= 1 )) && haveOptArgAttached=1 550 | fi 551 | (( haveOptArgAttached )) && optArgOpt=$optArgAttached optArgReq=$optArgAttached || { (( $# > 1 )) && { optArgReq=$2; haveOptArgAsNextArg=1; }; } 552 | # ---- BEGIN: CUSTOMIZE HERE 553 | case $optName in 554 | d|default|set-default) 555 | default=1 556 | ;; 557 | m|manage) 558 | manage=1 559 | ;; 560 | L|list-langs) 561 | listLangs=1 562 | ;; 563 | l|list) 564 | list=1 565 | ;; 566 | a|all) 567 | allInstalled=1 568 | ;; 569 | i|internals) 570 | internals=1 571 | ;; 572 | b|bare) 573 | bare=1 574 | ;; 575 | k|speak|speak=*) 576 | acceptOptArg=1 577 | speak=1 578 | text=$optArgOpt 579 | # If text is '-', read from stdin. 580 | [[ $text == '-' ]] && text=$( 0 )) && set -- "${operands[@]}"; unset allowOptsAfterOperands operands i optName isLong prefix optArgAttached haveOptArgAttached haveOptArgAsNextArg acceptOptArg needOptArg 606 | # ----- END: OPTIONS PARSING: "$@" now contains all operands (non-option arguments). 607 | 608 | # Check for incompatible options and validate number of operands. 609 | errMsg="Incompatible options specified." 610 | if (( manage )); then 611 | (( $# == 0 )) || dieSyntax 612 | (( (default + allInstalled + listLangs + list + speak + bare + internals) == 0 )) || dieSyntax "$errMsg" 613 | elif (( listLangs )); then 614 | (( $# == 0 )) || dieSyntax 615 | (( (default + list + speak + bare + internals + quiet) == 0 )) || dieSyntax "$errMsg" 616 | else 617 | (( allInstalled && ! (list || $# > 0) )) && dieSyntax "$errMsg" # Note: we tolerate -a when explicit voice names are specified, even though it's implied. 618 | (( bare && internals)) && dieSyntax "$errMsg" 619 | if (( quiet && ! speak )); then # -q to quiet printed output always makes sense when speaking is requested 620 | (( list )) && dieSyntax "$errMsg" # with listing voices, -q makes no sense 621 | (( default && $# == 1 )) || dieSyntax "$errMsg" # -q does make sense when setting a new default voice. 622 | fi 623 | fi 624 | errMsg= 625 | 626 | # -- Handle the exceptional synopsis forms first. 627 | 628 | if (( manage )); then 629 | openTtsSystemPrefs 630 | exit 0 631 | elif (( listLangs )); then 632 | # List the distinct, sorted set of language IDs only - by default among the active voices only, on request (-a) among all installed voices. 633 | { (( allInstalled )) && listInstalledVoices || listActiveVoices; } | egrep -o ' [a-z]{2}[_-]\w+ +#' | awk '{ print $1 }' | sort -u 634 | (( ${PIPESTATUS[0]} == 0 )) || die 635 | exit 0 636 | fi 637 | 638 | # -- Getting here means that one of the following command forms was specified: 639 | # [-d [newDefault]] 640 | # -l 641 | # voiceName... 642 | 643 | 644 | # -- Validate operands and prepare for actual processing later. 645 | if (( list )); then 646 | # Translate the language IDs, if any, to matching voice names. 647 | # If no language ID was specified, getVoiceNamesByLangId returns ALL installed/active voices. 648 | IFS=$'\n' read -d '' -ra voiceNames < <(getVoiceNamesByLangId $( (( allInstalled )) && printf %s '-a') "$@") 649 | (( ${#voiceNames[@]} > 0 )) || die "No installed voices match the specified languages, $*." 650 | set -- "${voiceNames[@]}" # set the resulting voices as operands to be processed below. 651 | elif (( default || $# == 0 )); then # get or set default voice 652 | if (( $# == 1 )); then # set new default voice 653 | setDefaultVoice "$1" || die 654 | (( quiet )) || echo "Default voice changed to:" 655 | # Leave the new default voice name as $1, because we will print information about it and/or speak text below. 656 | elif (( $# == 0 )); then # get current default voice 657 | # Set the current default voice name as $1, because we will print information about it and/or speak text below. 658 | set -- "$(getDefaultVoiceName)" 659 | if [[ -z $1 ]]; then 660 | cat <&2; 661 | ERROR: Failed to determine the default voice. 662 | This typically happens on a pristine system where the default voice has 663 | never been changed. 664 | Once you've changed it for the first time, $kTHIS_NAME will be able to 665 | determine it. You can change it with \`$kTHIS_NAME -d \`, or 666 | interactively via System Preferences (\`$kTHIS_NAME -m\`). 667 | EOF 668 | exit 1 669 | fi 670 | else # too many arguments 671 | dieSyntax 672 | fi 673 | else # explicit voice names were specified - they must be validated 674 | validateVoiceNames=1 675 | fi 676 | 677 | # The list of target voices - whether directly specified or derived above - 678 | # if any, is now contained in $@, and what's left is to print information 679 | # about each and, if requested, speak text for each. 680 | 681 | 682 | okCount=0 allVoicesList= infoLine= 683 | for voice; do 684 | 685 | # Validate the voice, if needed and/or get the info line for the voice at hand. 686 | infoLine= 687 | if (( validateVoiceNames || (speak && ${#text} == 0) || ! (bare || internals) )); then 688 | # Get and cache the list of all installed voices, as output by `say -v \?`. 689 | [[ -z $allVoicesList ]] && { allVoicesList=$(listInstalledVoices) || die; } 690 | # Note: This command both validates the voice name and returns the relevant `say -v \?` info line for potential later use. 691 | infoLine=$(grep -Ei "^$voice +[a-z]{2}[_-]\w+ +#" <<<"$allVoicesList") || { echo "WARNING: '$voice' is not an installed voice." >&2; continue; } 692 | fi 693 | 694 | # Output: 695 | if (( ! quiet )); then 696 | if (( bare )); then # print friendly voice *name* only 697 | printf '%s\n' "$voice" 698 | elif (( internals )); then # print the voice's internal identifiers 699 | printVoiceInternals "$voice" 700 | else # print the voice-specific line as output by `say -v \?`. 701 | printf '%s\n' "$infoLine" 702 | fi 703 | fi 704 | 705 | # Speak: If requested, also speak text for the voice at hand. 706 | if (( speak )); then 707 | # Speak specified or demo text. 708 | speakText "$voice" "$([[ -n $text ]] && printf %s "$text" || printf %s "${infoLine##*\#}" )" 709 | fi 710 | 711 | (( ++okCount )) 712 | 713 | done 714 | 715 | # Exit with 0, if at least one voice was successfully processed. 716 | (( okCount > 0 )) && exit 0 || exit 1 717 | 718 | #### 719 | # MAN PAGE MARKDOWN SOURCE 720 | # - Place a Markdown-formatted version of the man page for this script 721 | # inside the here-document below. 722 | # The document must be formatted to look good in all 3 viewing scenarios: 723 | # - as a man page, after conversion to ROFF with marked-man 724 | # - as plain text (raw Markdown source) 725 | # - as HTML (rendered Markdown) 726 | # Markdown formatting tips: 727 | # - GENERAL 728 | # To support plain-text rendering in the terminal, limit all lines to 80 chars., 729 | # and, for similar rendering as HTML, *end every line with 2 trailing spaces*. 730 | # - HEADINGS 731 | # - For better plain-text rendering, leave an empty line after a heading 732 | # marked-man will remove it from the ROFF version. 733 | # - The first heading must be a level-1 heading containing the utility 734 | # name and very brief description; append the manual-section number 735 | # directly to the CLI name; e.g.: 736 | # # foo(1) - does bar 737 | # - The 2nd, level-2 heading must be '## SYNOPSIS' and the chapter's body 738 | # must render reasonably as plain text, because it is printed to stdout 739 | # when `-h`, `--help` is specified: 740 | # Use 4-space indentation without markup for both the syntax line and the 741 | # block of brief option descriptions; represent option-arguments and operands 742 | # in angle brackets; e.g., '' 743 | # - All other headings should be level-2 headings in ALL-CAPS. 744 | # - TEXT 745 | # - Use NO indentation for regular chapter text; if you do, it will 746 | # be indented further than list items. 747 | # - Use 4-space indentation, as usual, for code blocks. 748 | # - Markup character-styling markup translates to ROFF rendering as follows: 749 | # `...` and **...** render as bolded (red) text 750 | # _..._ and *...* render as word-individually underlined text 751 | # - LISTS 752 | # - Indent list items by 2 spaces for better plain-text viewing, but note 753 | # that the ROFF generated by marked-man still renders them unindented. 754 | # - End every list item (bullet point) itself with 2 trailing spaces too so 755 | # that it renders on its own line. 756 | # - Avoid associating more than 1 paragraph with a list item, if possible, 757 | # because it requires the following trick, which hampers plain-text readability: 758 | # Use ' ' in lieu of an empty line. 759 | #### 760 | : <<'EOF_MAN_PAGE' 761 | # voices(1) - OS X text-to-speech voices 762 | 763 | ## SYNOPSIS 764 | 765 | Get or set or speak with the DEFAULT VOICE: 766 | 767 | voices [] [-d []] 768 | 769 | LIST INFORMATION about / speak with voices: 770 | 771 | voices [] ... 772 | 773 | List / speak with ALL VOICES, optionally FILTERED BY LANGUAGES: 774 | 775 | voices [] -l [...] 776 | 777 | LIST LANGUAGES among voices: 778 | 779 | voices -L [-a] 780 | 781 | MANAGE VOICES in System Preferences: 782 | 783 | voices -m 784 | 785 | Shared options (synopsis forms 1-3): 786 | 787 | -a target all installed voices (default: only active ones) 788 | -k speak demo text with all targeted voices 789 | -k"" speak specified text 790 | -k- speak text provided via stdin 791 | -b output format: print voice names only 792 | -i output format: print voice internals 793 | -q quiet mode: no printed output 794 | 795 | Standard options: `--help`, `--man`, `--version`, `--home` 796 | 797 | ## DESCRIPTION 798 | 799 | `voices` sets the default voice for OS X's TTS (text-to-speech) synthesis or 800 | returns information about the default, active and installed voices. 801 | Additionally, it can speak either the demo text or specified text with 802 | multiple voices. 803 | 804 | Case doesn't matter when specifying voice or language names. 805 | 806 | * Specify voice names as they appear in System Preferences > 807 | Dictation & Speeech and in the output from `say -v \?`. 808 | 809 | * Specify languages as two-character language IDs (e.g., `en`), optionally 810 | followed by `_` and a region identifier (e.g., `en_US`). 811 | 812 | Options `-l` and `-L` target all *active* voices by default, which are 813 | typically a a subset of all *installed* voices, and constitute the set of 814 | voices selected for active use in System Preferences > Dictation & Speech > 815 | Text to Speech. 816 | Adding `-a` targets all installed voices. 817 | 818 | The `-k` option for speaking with all targeted voices as well as other 819 | shared options are discussed further below. Without `-k`, only printed output 820 | is produced; conversely, `-q` silences printed output. 821 | 822 | * 1st synopsis form: `[-d []]`, `[--default []]` 823 | Returns information about the default voice or sets a new default voice. 824 | Note that any installed voice can be specified as the default voice, even 825 | if it is not among the set of active voices. 826 | 827 | * 2nd synopsis form: `...` 828 | Lists information about the specified voices (whether active or not). 829 | 830 | * 3rd synopsis form: `-l [...]`, `--list [...]` 831 | Lists information about active, installed, or voices matching one or more 832 | specified languages. 833 | Lists all active voices by default; `-a` lists all installed ones. 834 | If at least one `` operand is given, the list of active voices (by 835 | default) / installed voices (with `-a`) is filtered to output only those 836 | matching the specified language(s). 837 | `` values may be mere language IDs (e.g., `en`) or language + region 838 | IDs (e.g., `en_US`); e.g., `en` matches all English voices irrespective of 839 | region, whereas `en_US` matches only US English voices. 840 | 841 | * 4th synopsis form: `-L`, `--list-langs` 842 | Lists the distinct set of languages supported among all active (by default) 843 | or all installed (`-a`) voices. 844 | Languages are listed as language + region identifiers, e.g., `en_US`. 845 | 846 | * 5th synopsis form: `-m`, `--manage` 847 | Opens System Preferences > Dictation & Speech, where you can manage the 848 | set of active voices, install additional voices, and control other aspects 849 | of text-to-speech synthesis. 850 | 851 | ## SHARED OPTIONS 852 | 853 | These options complement the main options, which determine the synopsis form, 854 | discussed above. 855 | 856 | ### General Options 857 | 858 | * `-q` 859 | Quiet mode: suppresses printed output, such as when only speech output 860 | (`-k`) is desired or when the new default voice should be set quietly. 861 | Cannot be combined with `-L`, whose sole purpose is to print 862 | information. 863 | 864 | ### Speaking options (synopsis forms 1-3): 865 | 866 | Note that if the command targets multiple voices, speaking happens 867 | after each voice's information has been printed (unless printing is 868 | suppressed with `-q`). 869 | 870 | * `-k`, `--speak` (no argument) 871 | Speaks each targeted voice's demo text. 872 | 873 | * `-k""`, `--speak=""` 874 | Speaks the specified text using each targeted voice. 875 | Note that `""` must be directly attached to the option and should 876 | generally be quoted to protect it from (unwanted) interpretation by the 877 | shell. 878 | 879 | * `-k-`, `--speak=-` 880 | Speaks text provided via stdin using each targeted voice. 881 | 882 | ### Printed-Output Options (synopsis forms 1-3) 883 | 884 | By default, voice information printed is in the form provided by the standard 885 | `say` utility when invoked as `say -v \?`, which is: 886 | ` # ` 887 | 888 | The following, mutually exclusive options modify this behavior: 889 | 890 | * `-b`, `--bare` 891 | Outputs mere voice names only. 892 | 893 | * `-i`, `--internals` 894 | Outputs internal voice identifiers, as used by the system. 895 | 896 | ## STANDARD OPTIONS 897 | 898 | All standard options must be provided as the only argument; all of them provide 899 | information only. 900 | 901 | * `-h, --help` 902 | Prints the contents of the synopsis chapter to stdout for quick reference. 903 | 904 | * `--man` 905 | Displays this manual page, which is a helpful alternative to using `man`, 906 | if the manual page isn't installed. 907 | 908 | * `--version` 909 | Prints version information. 910 | 911 | * `--home` 912 | Opens this utility's home page in the system's default web browser. 913 | 914 | ## LICENSE 915 | 916 | For license information, bug reports, and more, visit this utility's home page 917 | by running `voices --home` 918 | 919 | ## EXAMPLES 920 | 921 | # List all active voices; add -a to list all installed ones. 922 | voices -l 923 | 924 | # Print information about the default voice and speak its demo text. 925 | voices -d -k 926 | 927 | # Print information about voice 'Alex'. 928 | voices alex 929 | 930 | # Make 'Alex' the new default voice, print information about it, and 931 | # speak text that announces the change. 932 | voices -k'The new default voice is Alex.' -d alex 933 | 934 | # List languages for which at least one voice is active. 935 | voices -L 936 | 937 | # List active French voices. 938 | voices -l fr 939 | 940 | # Speak the respective demo text with all active voices. 941 | voices -l -k 942 | 943 | # Speak "hello" first with Alex, then with Jill, suppressing printed 944 | # output. 945 | voices -k"hello" -q alex jill 946 | 947 | # Print information about all active Spanish voices and speak their 948 | # respective demo text. 949 | voices -k -l es 950 | 951 | EOF_MAN_PAGE 952 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "voices", 3 | "description": "OSX CLI for changing the default TTS (text-to-speech) voice and printing information about and speaking text with multiple voices.", 4 | "private": false, 5 | "version": "0.3.4", 6 | "os": [ 7 | "darwin" 8 | ], 9 | "preferGlobal": true, 10 | "bin": { 11 | "voices": "bin/voices" 12 | }, 13 | "homepage": "https://github.com/mklement0/voices", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/mklement0/voices.git" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/mklement0/voices/issues" 20 | }, 21 | "scripts": { 22 | "test": "make test" 23 | }, 24 | "keywords": [ 25 | "voice", 26 | "TTS", 27 | "text-to-speech", 28 | "default", 29 | "CLI", 30 | "speak", 31 | "language" 32 | ], 33 | "author": "Michael Klement (http://same2u.net)", 34 | "license": "MIT", 35 | "net_same2u": { 36 | "make_pkg": { 37 | "tocOn": true, 38 | "tocTitle": "**Contents**", 39 | "manOn": true 40 | } 41 | }, 42 | "devDependencies": { 43 | "doctoc": "^0.13.0", 44 | "json": "^9.0.3", 45 | "marked-man": "^0.1.5", 46 | "replace": "^0.3.0", 47 | "semver": "^4.2.0", 48 | "tap": "^0.6.0", 49 | "urchin": "^0.0.5" 50 | }, 51 | "man": "./man/voices.1" 52 | } 53 | -------------------------------------------------------------------------------- /test/0 Prerequisites: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Helper function for error reporting. 4 | die() { [ $# -gt 0 ] && echo "$*" >&2; exit 1; } 5 | 6 | requiredUtils='bash say osascript' # Add additional prerequisites, if any, here. 7 | for requiredUtil in $requiredUtils; do 8 | [ -n "$(command -v "$requiredUtil")" ] || die "MISSING PREREQUISITE: \`$requiredUtil\` is required to run tests." 9 | done 10 | -------------------------------------------------------------------------------- /test/Correctly reports default voice: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # --- 4 | # IMPORTANT: Use the following statement at the TOP OF EVERY TEST SCRIPT 5 | # to ensure that this package's 'bin/' subfolder is added to the path so that 6 | # this package's CLIs can be invoked by their mere filename in the rest 7 | # of the script. 8 | # --- 9 | PATH=${PWD%%/test*}/bin:$PATH 10 | 11 | # Helper function for error reporting. 12 | die() { (( $# > 0 )) && echo "ERROR: $*" >&2; exit 1; } 13 | 14 | defaultVoice=$(defaults read com.apple.speech.voice.prefs SelectedVoiceName) 15 | 16 | shopt -s nocasematch 17 | [[ $(voices -b) == "$defaultVoice" ]] || die "Default voice reported did not match '$defaultVoice'." 18 | 19 | exit 0 20 | -------------------------------------------------------------------------------- /test/Option -L with -a reports all installed languages: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # --- 4 | # IMPORTANT: Use the following statement at the TOP OF EVERY TEST SCRIPT 5 | # to ensure that this package's 'bin/' subfolder is added to the path so that 6 | # this package's CLIs can be invoked by their mere filename in the rest 7 | # of the script. 8 | # --- 9 | PATH=${PWD%%/test*}/bin:$PATH 10 | 11 | # Helper function for error reporting. 12 | die() { (( $# > 0 )) && echo "ERROR: $*" >&2; exit 1; } 13 | 14 | # Derive distinct set of languages from `say -v \?` output. 15 | allLangsRef=$(say -v \? | grep -Eo " [a-z]{2}[_-]\w+ " | tr -d ' ' | sort -u) 16 | 17 | # Do the same with `voices -L -a`. 18 | allLangs=$(voices -L -a) 19 | 20 | # Ensure the two match. 21 | [[ "$allLangs" == "$allLangsRef" ]] || die "Lists of installed languages didn't match: "$'\n'"$(diff <(echo "$allLangs") <(echo "$allLangsRef"))" 22 | 23 | exit 0 24 | -------------------------------------------------------------------------------- /test/Option -d changes default voice: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # --- 4 | # IMPORTANT: Use the following statement at the TOP OF EVERY TEST SCRIPT 5 | # to ensure that this package's 'bin/' subfolder is added to the path so that 6 | # this package's CLIs can be invoked by their mere filename in the rest 7 | # of the script. 8 | # --- 9 | PATH=${PWD%%/test*}/bin:$PATH 10 | 11 | # Helper function for error reporting. 12 | die() { (( $# > 0 )) && echo "ERROR: $*" >&2; exit 1; } 13 | 14 | defaultVoice=$(defaults read com.apple.speech.voice.prefs SelectedVoiceName) || die "Couldn't determine current default voice." 15 | 16 | # Get the first voice that is not the current default voice. 17 | tempDefaultVoiceLine=$(say -v \? | grep -Ev -m 1 "^$defaultVoice +[a-z]{2}[_-]\w+ +#") || die "Couldn't determine temp. default voice." 18 | tempDefaultVoice=$(sed -E 's/^(.+[^ ]) +[a-z]{2}[_-][[:alnum:]]+ +#.*$/\1/' <<<"$tempDefaultVoiceLine") 19 | 20 | # echo "??before setting to '$tempDefaultVoice'" 21 | 22 | voices -qd "$tempDefaultVoice" || die "Failed to set default voice to '$tempDefaultVoice'." 23 | 24 | # echo "??after" 25 | 26 | actualNewdefaultVoice=$(defaults read com.apple.speech.voice.prefs SelectedVoiceName) || die "Couldn't determine new default voice." 27 | 28 | # echo "??defaults re-read" 29 | 30 | shopt -s nocasematch 31 | [[ $actualNewdefaultVoice == "$tempDefaultVoice" ]] || die "Temp. default voice '$tempDefaultVoice' differs from actual default voice, '$actualNewdefaultVoice'." 32 | 33 | # echo "??defaults compared" 34 | 35 | # Rever to previous default voice. 36 | # !! After having changed the default voice only recently, a subsequent attempt takes much longer, up to 10 seconds (why??). 37 | voices -qd "$defaultVoice" || die "Failed to revert to previous default voice, '$defaultVoice'" 38 | 39 | # echo "??reset to $defaultVoice" 40 | 41 | exit 0 42 | -------------------------------------------------------------------------------- /test/Option -l reports subset of installed voices: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # --- 4 | # IMPORTANT: Use the following statement at the TOP OF EVERY TEST SCRIPT 5 | # to ensure that this package's 'bin/' subfolder is added to the path so that 6 | # this package's CLIs can be invoked by their mere filename in the rest 7 | # of the script. 8 | # --- 9 | PATH=${PWD%%/test*}/bin:$PATH 10 | 11 | # Helper function for error reporting. 12 | die() { (( $# > 0 )) && echo "ERROR: $*" >&2; exit 1; } 13 | 14 | # Get list of all installed voices. 15 | allVoices=$(say -v \?) 16 | 17 | # Using 'voices', get list of *active* voices; i.e., the subset selectd for active use via System Preferences > Dication & Speech > Text to Speech. 18 | activeVoices=$(voices -l) 19 | 20 | # The set of voices the list of all voices and the list of active voices should have in common 21 | # is the list of active voices. 22 | # Note that this test succeeds even if the set of active voices is the same as the list of installed voices, 23 | # but we have no easy way of creating a reference list of active voices, because `say -v \?` invariably reports all installed voices. 24 | [[ $(comm -12 <(echo "$allVoices") <(echo "$activeVoices")) == "$activeVoices" ]] || die "Active voices not a subset of installed voices." 25 | 26 | exit 0 27 | -------------------------------------------------------------------------------- /test/Option -l with -a reports all installed voices: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # --- 4 | # IMPORTANT: Use the following statement at the TOP OF EVERY TEST SCRIPT 5 | # to ensure that this package's 'bin/' subfolder is added to the path so that 6 | # this package's CLIs can be invoked by their mere filename in the rest 7 | # of the script. 8 | # --- 9 | PATH=${PWD%%/test*}/bin:$PATH 10 | 11 | # Helper function for error reporting. 12 | die() { (( $# > 0 )) && echo "ERROR: $*" >&2; exit 1; } 13 | 14 | allVoicesRef=$(say -v \?) 15 | 16 | [[ $(voices -a -l) == "$allVoicesRef" ]] || die "Lists of installed voices didn't match." 17 | 18 | exit 0 19 | -------------------------------------------------------------------------------- /test/Option -l with language filter works correctly: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # --- 4 | # IMPORTANT: Use the following statement at the TOP OF EVERY TEST SCRIPT 5 | # to ensure that this package's 'bin/' subfolder is added to the path so that 6 | # this package's CLIs can be invoked by their mere filename in the rest 7 | # of the script. 8 | # --- 9 | PATH=${PWD%%/test*}/bin:$PATH 10 | 11 | # Helper function for error reporting. 12 | die() { (( $# > 0 )) && echo "ERROR: $*" >&2; exit 1; } 13 | 14 | defaultVoice=$(defaults read com.apple.speech.voice.prefs SelectedVoiceName) || die "Couldn't determine current default voice." 15 | 16 | # Get all *installed* English-language voices, irrespective of region. 17 | # Note that say -v \? always reports all *installed* voices, irrespective of which are selected for active use. 18 | enVoicesRef=$(say -v \? | grep -E "^.+[^ ] +en[_-]\w+ +#.+") || die "Couldn't determine English references voices." 19 | 20 | # Let `voices` do the same, and ensure the results match. 21 | [[ $(voices -a -l 'en') == "$enVoicesRef" ]] || die "Filtered list of English voices doesn't match the reference list, '$enVoicesRef'." 22 | 23 | exit 0 24 | -------------------------------------------------------------------------------- /test/Option -m opens the speech System Preferences pane: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # --- 4 | # IMPORTANT: Use the following statement at the TOP OF EVERY TEST SCRIPT 5 | # to ensure that this package's 'bin/' subfolder is added to the path so that 6 | # this package's CLIs can be invoked by their mere filename in the rest 7 | # of the script. 8 | # --- 9 | PATH=${PWD%%/test*}/bin:$PATH 10 | 11 | # Helper function for error reporting. 12 | die() { (( $# > 0 )) && echo "ERROR: $*" >&2; exit 1; } 13 | 14 | voices -m || die 15 | 16 | # Note: We can only verify the target *pane*, not the specific *anchor* on that pane. 17 | osascript <<'EOF' || die 18 | set AppleScript's text item delimiters to "." 19 | set minorOsNum to text item 2 of system version of (system info) as number 20 | tell application "System Preferences" 21 | if not it is running then error "System Preferences app is not running." 22 | set currPane to "" 23 | try 24 | set currPane to id of current pane 25 | end try 26 | if currPane = "" then error "No System Preferences pane is active." 27 | if minorOsNum >= 12 then # Sierra+ 28 | if currPane ≠ "com.apple.preference.universalaccess" then error "The Accessibility pane is not active; instead, it is " & currPane 29 | else # El Capitan- 30 | if currPane ≠ "com.apple.preference.speech" then error "The Dictation & Speech pane is not active; instead, it is " & currPane 31 | end if 32 | end tell 33 | activate application "Terminal" 34 | EOF 35 | 36 | exit 0 37 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # Missing tests 2 | 3 | * Option -k (speech output) is curently not being tested, as that would require programmatic listening to the sounds produced. 4 | -------------------------------------------------------------------------------- /test/standard CLI options/Option --version prints version: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # --- 4 | # IMPORTANT: Use the following statement at the TOP OF EVERY TEST SCRIPT 5 | # to ensure that this package's 'bin/' subfolder is added to the path so that 6 | # this package's CLIs can be invoked by their mere filename in the rest 7 | # of the script. 8 | # --- 9 | PATH=${PWD%%/test*}/bin:$PATH 10 | 11 | # Helper function for error reporting. 12 | die() { (( $# > 0 )) && echo "ERROR: $*" >&2; exit 1; } 13 | 14 | # Execute your tests as shell commands below. 15 | # An exit code of zero signals success, any other code signals an error 16 | # and causes this test to fail. 17 | # See https://github.com/tlevine/urchin. 18 | 19 | voices --version | egrep -Ew 'v?[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+' || die "No major.minor.patch version number detected in the output." 20 | 21 | exit 0 22 | 23 | -------------------------------------------------------------------------------- /test/standard CLI options/Options -h and --help print CLI help: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # --- 4 | # IMPORTANT: Use the following statement at the TOP OF EVERY TEST SCRIPT 5 | # to ensure that this package's 'bin/' subfolder is added to the path and this 6 | # package's CLIs can therefore be invoked by their mere filename in the rest 7 | # of the script. 8 | # --- 9 | PATH=${PWD%%/test*}/bin:$PATH 10 | 11 | # Helper function for error reporting. 12 | die() { (( $# > 0 )) && echo "ERROR: $*" >&2; exit 1; } 13 | 14 | # Execute your tests as shell commands below. 15 | # An exit code of zero signals success, any other code signals an error 16 | # and causes this test to fail. 17 | # See https://github.com/tlevine/urchin. 18 | 19 | cli='voices' 20 | errMsg="CLI name '$cli' not found in stdout output." 21 | 22 | for opt in '-h' '--help'; do 23 | voices -h | fgrep -qw "$cli" || die "Option $opt: $errMsg" 24 | done 25 | 26 | exit 0 27 | -------------------------------------------------------------------------------- /test/syntax/Unknown options cause an error: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # --- 4 | # IMPORTANT: Use the following statement at the TOP OF EVERY TEST SCRIPT 5 | # to ensure that this package's 'bin/' subfolder is added to the path so that 6 | # this package's CLIs can be invoked by their mere filename in the rest 7 | # of the script. 8 | # --- 9 | PATH=${PWD%%/test*}/bin:$PATH 10 | 11 | # Helper function for error reporting. 12 | die() { (( $# > 0 )) && echo "ERROR: $*" >&2; exit 1; } 13 | 14 | # Choose an unknown option. 15 | unknownOption=-9 16 | 17 | # Ensure that using an unknown option results in an error. 18 | voices $unknownOption && die "Unknown option was not detected." 19 | 20 | # Ditto for combining the unknown option with a known one. 21 | voices -d $unknownOption && die "Unknown option was not detected." 22 | 23 | exit 0 24 | --------------------------------------------------------------------------------