Add chime endpoint to TTS server
POST /chime {name} plays chimes/<name>.wav or .ogg via pacat.
Goes through the same queue as speak so playback stays ordered.
chimes/ directory holds the audio files (not committed).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2
chimes/README.md
Normal file
2
chimes/README.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Drop .wav or .ogg chime files here. Names match POST /chime {"name": "..."}
|
||||||
|
# Suggested: ready.wav, dispatch.wav, cancel.wav, error.wav
|
||||||
@@ -36,6 +36,18 @@ export class Tts_Client {
|
|||||||
return res.json() // { voices: [{name, description, active}], current }
|
return res.json() // { voices: [{name, description, active}], current }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async chime(name) {
|
||||||
|
const res = await fetch(`${this._url}/chime`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(data.error ?? `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async set_voice(name) {
|
async set_voice(name) {
|
||||||
const res = await fetch(`${this._url}/voice`, {
|
const res = await fetch(`${this._url}/voice`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -12,17 +12,21 @@
|
|||||||
* GET /voices → 200 { "voices": ["rommie", ...], "current": "rommie" | null }
|
* GET /voices → 200 { "voices": ["rommie", ...], "current": "rommie" | null }
|
||||||
* POST /voice { "name": "rommie" }
|
* POST /voice { "name": "rommie" }
|
||||||
* → 200 { "ok": true, "name": "rommie", "path": "..." }
|
* → 200 { "ok": true, "name": "rommie", "path": "..." }
|
||||||
|
* POST /chime { "name": "ready" }
|
||||||
|
* → 200 { "ok": true } plays chimes/<name>.wav (or .ogg)
|
||||||
* GET /health → 200 { "ok": true }
|
* GET /health → 200 { "ok": true }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as http from 'node:http'
|
import * as http from 'node:http'
|
||||||
import * as fs from 'node:fs'
|
import * as fs from 'node:fs'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import yaml from 'js-yaml'
|
import { spawn } from 'node:child_process'
|
||||||
|
import yaml from 'js-yaml'
|
||||||
import { Chatterbox_Tts } from './lib/chatterbox-tts.mjs'
|
import { Chatterbox_Tts } from './lib/chatterbox-tts.mjs'
|
||||||
|
|
||||||
const PORT = parseInt(process.env.TTS_PORT ?? '11500')
|
const PORT = parseInt(process.env.TTS_PORT ?? '11500')
|
||||||
const VOICES_FILE = path.join(import.meta.dirname, 'voices.yaml')
|
const VOICES_FILE = path.join(import.meta.dirname, 'voices.yaml')
|
||||||
|
const CHIMES_DIR = path.join(import.meta.dirname, 'chimes')
|
||||||
|
|
||||||
function reload_voices() {
|
function reload_voices() {
|
||||||
try {
|
try {
|
||||||
@@ -69,6 +73,14 @@ function send(res, status, body) {
|
|||||||
res.end(payload)
|
res.end(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function play_file(file_path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const player = spawn('pacat', ['--playback', file_path])
|
||||||
|
player.on('close', code => code === 0 ? resolve() : reject(new Error(`pacat exited ${code}`)))
|
||||||
|
player.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
if (req.method === 'GET' && req.url === '/health') {
|
if (req.method === 'GET' && req.url === '/health') {
|
||||||
return send(res, 200, { ok: true })
|
return send(res, 200, { ok: true })
|
||||||
@@ -98,6 +110,26 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return send(res, 200, { ok: true, name, path: voices[name].path })
|
return send(res, 200, { ok: true, name, path: voices[name].path })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/chime') {
|
||||||
|
let body
|
||||||
|
try { body = await read_body(req) } catch {
|
||||||
|
return send(res, 400, { error: 'invalid JSON' })
|
||||||
|
}
|
||||||
|
const { name } = body
|
||||||
|
if (!name) return send(res, 400, { error: 'name required' })
|
||||||
|
// Try .wav then .ogg
|
||||||
|
const wav = path.join(CHIMES_DIR, `${name}.wav`)
|
||||||
|
const ogg = path.join(CHIMES_DIR, `${name}.ogg`)
|
||||||
|
const file = fs.existsSync(wav) ? wav : fs.existsSync(ogg) ? ogg : null
|
||||||
|
if (!file) return send(res, 404, { error: `chime not found: ${name}` })
|
||||||
|
try {
|
||||||
|
await enqueue(() => play_file(file))
|
||||||
|
return send(res, 200, { ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
return send(res, 500, { error: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/speak') {
|
if (req.method === 'POST' && req.url === '/speak') {
|
||||||
let body
|
let body
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user