Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.21.1"),
.package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.1.0"),
.package(url: "https://github.com/gaelic-ghost/SpeakSwiftly.git", from: "8.0.0"),
.package(url: "https://github.com/gaelic-ghost/SpeakSwiftly.git", from: "9.0.0"),
.package(url: "https://github.com/ml-explore/mlx-swift-lm.git", exact: "3.31.3"),
.package(url: "https://github.com/gaelic-ghost/TextForSpeech.git", from: "0.21.0"),
.package(url: "https://github.com/gaelic-ghost/TextForSpeech.git", from: "0.22.0"),
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.1.3"),
.package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.12.0"),
.package(
Expand Down
6 changes: 6 additions & 0 deletions Sources/SpeakSwiftlyServer/HTTP/HTTPSpeechRoutes.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Hummingbird
import SpeakSwiftly

func registerHTTPSpeechRoutes(
on router: Router<BasicRequestContext>,
Expand All @@ -21,6 +22,7 @@ func registerHTTPSpeechRoutes(
requestContext: payload.resolvedRequestContext(
defaults: httpSpeechRequestContextDefaults(
route: "/speech/live",
reqPurpose: .speech,
topic: "live-speech",
),
),
Expand All @@ -45,6 +47,7 @@ func registerHTTPSpeechRoutes(
requestContext: payload.resolvedRequestContext(
defaults: httpSpeechRequestContextDefaults(
route: "/speech/files",
reqPurpose: .audioFile,
topic: "retained-audio-file",
),
),
Expand All @@ -66,6 +69,7 @@ func registerHTTPSpeechRoutes(
$0.model(
requestContextDefaults: httpSpeechRequestContextDefaults(
route: "/speech/batches",
reqPurpose: .audioFile,
topic: "retained-audio-batch",
),
)
Expand All @@ -78,9 +82,11 @@ func registerHTTPSpeechRoutes(

private func httpSpeechRequestContextDefaults(
route: String,
reqPurpose: SpeakSwiftly.RequestContext.RequestPurpose,
topic: String,
) -> SpeechRequestContextDefaults {
.init(
reqPurpose: reqPurpose,
source: "http",
topic: topic,
attributes: [
Expand Down
11 changes: 11 additions & 0 deletions Sources/SpeakSwiftlyServer/Host/ServerModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,30 @@ func supportedSpeechBackendDescription() -> String {
}

struct SpeechRequestContextDefaults {
var reqPurpose: SpeakSwiftly.RequestContext.RequestPurpose
var source: String?
var topic: String?
var cwd: String?
var repoRoot: String?
var attributes: [String: String]
var prefacePolicy: SpeakSwiftly.RequestContext.PrefacePolicy?

init(
reqPurpose: SpeakSwiftly.RequestContext.RequestPurpose = .speech,
source: String? = nil,
topic: String? = nil,
cwd: String? = nil,
repoRoot: String? = nil,
attributes: [String: String] = [:],
prefacePolicy: SpeakSwiftly.RequestContext.PrefacePolicy? = nil,
) {
self.reqPurpose = reqPurpose
self.source = source
self.topic = topic
self.cwd = cwd
self.repoRoot = repoRoot
self.attributes = attributes
self.prefacePolicy = prefacePolicy
}
}

Expand All @@ -40,6 +46,7 @@ func makeSpeechRequestContext(
defaults: SpeechRequestContextDefaults = .init(),
) -> SpeakSwiftly.RequestContext? {
let merged = SpeakSwiftly.RequestContext(
reqPurpose: requestContext?.reqPurpose ?? defaults.reqPurpose,
source: requestContext?.source ?? defaults.source,
topic: requestContext?.topic ?? defaults.topic,
cwd: cwd ?? requestContext?.cwd ?? defaults.cwd,
Expand All @@ -48,13 +55,17 @@ func makeSpeechRequestContext(
defaults: defaults.attributes,
request: requestContext?.attributes ?? [:],
),
prefacePolicy: requestContext?.prefacePolicy ?? defaults.prefacePolicy,
)
guard
merged.source != nil
|| merged.topic != nil
|| merged.cwd != nil
|| merged.repoRoot != nil
|| !merged.attributes.isEmpty
|| merged.prefacePolicy != nil
|| requestContext?.reqPurpose != nil
|| defaults.reqPurpose != .speech
else {
return nil
}
Expand Down
11 changes: 11 additions & 0 deletions Sources/SpeakSwiftlyServer/MCP/MCPClientIdentity.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import MCP
import SpeakSwiftly

struct MCPClientInfoSnapshot {
let name: String
Expand Down Expand Up @@ -42,12 +43,22 @@ func mcpSpeechRequestContextDefaults(
}

return .init(
reqPurpose: mcpRequestPurpose(for: toolName),
source: "mcp",
topic: toolName,
attributes: attributes,
)
}

private func mcpRequestPurpose(for toolName: String) -> SpeakSwiftly.RequestContext.RequestPurpose {
switch toolName {
case "generate_audio_file", "generate_batch":
.audioFile
default:
.speech
}
}

private func mcpClientDisplayName(from clientInfo: MCPClientInfoSnapshot) -> String {
let displayName = if let title = clientInfo.title, !title.isEmpty {
title
Expand Down
2 changes: 1 addition & 1 deletion Sources/SpeakSwiftlyServer/MCP/MCPResources.swift
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ private func voiceProfilesGuideMarkdown() -> String {
7. Use `update_voice_profile_name` when the user wants to keep a user-owned stored voice but correct or improve its visible profile name.
8. Use `reroll_voice_profile` when the user wants SpeakSwiftly to rebuild one user-owned stored profile from its original source inputs while keeping the same profile name. System profile rerolls create or target a user-owned copy in SpeakSwiftly rather than mutating the built-in in place.
9. Provide `transcript` to `create_voice_profile_from_audio` when the user knows the spoken words already; omit it only when transcription is actually needed.
10. The MCP surface fills client and tool provenance in `request_context` by default; pass `cwd`, `repo_root`, or `request_context` only when path or caller metadata needs to be more specific.
10. The MCP surface fills client, tool, and request-purpose provenance in `request_context` by default; pass `cwd`, `repo_root`, or `request_context` only when path or caller metadata needs to be more specific. Caller-provided `request_context` values use the shared `TextForSpeech.RequestContext` shape, including required `reqPurpose` and optional `prefacePolicy`.
11. Use `delete_voice_profile` only after confirming the exact `profile_name`, especially when multiple similar profiles exist. Ordinary deletion is for user-owned profiles; system-authored built-ins are maintained by SpeakSwiftly's bundled system-profile install and refresh behavior.

Drafting guidance:
Expand Down
6 changes: 3 additions & 3 deletions Sources/SpeakSwiftlyServer/MCP/MCPToolCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ enum MCPToolCatalog {
static let definitions: [Tool] = [
Tool(
name: "generate_speech",
description: "Queue live speech playback with a stored SpeakSwiftly voice profile. Use this when the user wants audible output now. The server fills MCP client and tool provenance in request_context by default; optionally provide profile_name to override the server's configured default voice profile plus text_profile_id, request_context, cwd, and repo_root when the input needs richer caller metadata.",
description: "Queue live speech playback with a stored SpeakSwiftly voice profile. Use this when the user wants audible output now. The server fills MCP client, tool, and request-purpose provenance in request_context by default; optionally provide profile_name to override the server's configured default voice profile plus text_profile_id, request_context, cwd, and repo_root when the input needs richer caller metadata.",
inputSchema: [
"type": "object",
"required": ["text"],
Expand All @@ -22,7 +22,7 @@ enum MCPToolCatalog {
),
Tool(
name: "generate_audio_file",
description: "Queue one retained generated-audio file instead of live playback. Use this when the user wants a saved artifact they can inspect or reuse later. The server fills MCP client and tool provenance in request_context by default; optionally provide profile_name to override the server's configured default voice profile plus request_context when the downstream artifact should retain richer caller metadata.",
description: "Queue one retained generated-audio file instead of live playback. Use this when the user wants a saved artifact they can inspect or reuse later. The server fills MCP client, tool, and request-purpose provenance in request_context by default; optionally provide profile_name to override the server's configured default voice profile plus request_context when the downstream artifact should retain richer caller metadata.",
inputSchema: [
"type": "object",
"required": ["text"],
Expand All @@ -38,7 +38,7 @@ enum MCPToolCatalog {
),
Tool(
name: "generate_batch",
description: "Queue a retained generated-audio batch from multiple items under one voice profile. Use this when the user wants several output files produced together. The server fills MCP client and tool provenance in each item request_context by default; optionally provide profile_name to override the server's configured default voice profile. Each item may carry its own request_context payload.",
description: "Queue a retained generated-audio batch from multiple items under one voice profile. Use this when the user wants several output files produced together. The server fills MCP client, tool, and request-purpose provenance in each item request_context by default; optionally provide profile_name to override the server's configured default voice profile. Each item may carry its own request_context payload.",
inputSchema: [
"type": "object",
"required": ["items"],
Expand Down
4 changes: 3 additions & 1 deletion Tests/SpeakSwiftlyServerLibraryTests/HTTPWorkflowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ extension ServerTests {
uri: "/speech/live",
method: .post,
headers: [.contentType: "application/json"],
body: byteBuffer(#"{"text":"Route test","text_profile_id":"swift-docs","request_context":{"source":"http","topic":"route-coverage","attributes":{"caller.app":"SpeakSwiftlyServerLibraryTests","caller.project":"SpeakSwiftlyServer","surface":"http"}},"cwd":"./Sources","repo_root":".","qwen_pre_model_text_chunking":true}"#),
body: byteBuffer(#"{"text":"Route test","text_profile_id":"swift-docs","request_context":{"reqPurpose":"speech","source":"http","topic":"route-coverage","attributes":{"caller.app":"SpeakSwiftlyServerLibraryTests","caller.project":"SpeakSwiftlyServer","surface":"http"}},"cwd":"./Sources","repo_root":".","qwen_pre_model_text_chunking":true}"#),
)
let speakJSON = try jsonObject(from: speakResponse.body)
let speakJobID = try #require(speakJSON["request_id"] as? String)
Expand All @@ -307,6 +307,7 @@ extension ServerTests {
#expect(
queuedSpeechInvocation.requestContext
== SpeakSwiftly.RequestContext(
reqPurpose: .speech,
source: "http",
topic: "route-coverage",
cwd: "./Sources",
Expand Down Expand Up @@ -338,6 +339,7 @@ extension ServerTests {
#expect(
queuedSpeechFileArtifact.requestContext
== SpeakSwiftly.RequestContext(
reqPurpose: .audioFile,
source: "http",
topic: "retained-audio-file",
attributes: [
Expand Down
38 changes: 38 additions & 0 deletions Tests/SpeakSwiftlyServerLibraryTests/HostStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,42 @@ import Testing
// MARK: - Host State Tests

extension ServerTests {
@Test func `speech request context keeps purpose and preface only contexts`() {
let purposeOnly = makeSpeechRequestContext(
cwd: nil,
repoRoot: nil,
requestContext: nil,
defaults: .init(reqPurpose: .audioFile),
)
#expect(purposeOnly == SpeakSwiftly.RequestContext(reqPurpose: .audioFile))

let callerPurposeOnly = makeSpeechRequestContext(
cwd: nil,
repoRoot: nil,
requestContext: SpeakSwiftly.RequestContext(reqPurpose: .audioStream),
)
#expect(callerPurposeOnly == SpeakSwiftly.RequestContext(reqPurpose: .audioStream))

let prefaceOnly = makeSpeechRequestContext(
cwd: nil,
repoRoot: nil,
requestContext: SpeakSwiftly.RequestContext(
reqPurpose: .speech,
prefacePolicy: .never,
),
)
#expect(
prefaceOnly
== SpeakSwiftly.RequestContext(
reqPurpose: .speech,
prefacePolicy: .never,
),
)

let defaultSpeech = makeSpeechRequestContext(cwd: nil, repoRoot: nil, requestContext: nil)
#expect(defaultSpeech == nil)
}

@Test func `shared playback and queue snapshots keep transport response field names`() throws {
let activeRequest = ActiveRequestSnapshot(id: "request-1", op: "generate_speech", profileName: "default")
let queuedRequest = QueuedRequestSnapshot(
Expand Down Expand Up @@ -690,6 +726,7 @@ extension ServerTests {
profileName: "default",
textProfileID: "swift-docs",
requestContext: .init(
reqPurpose: .speech,
source: "embedded-session",
topic: "state-actions",
cwd: "./Sources",
Expand All @@ -709,6 +746,7 @@ extension ServerTests {
#expect(
firstQueuedSpeechInvocation.requestContext
== SpeakSwiftly.RequestContext(
reqPurpose: .speech,
source: "embedded-session",
topic: "state-actions",
cwd: "./Sources",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ extension ServerTests {
mcpPOSTRequest(
body: mcpCallToolRequestJSON(
name: "generate_speech",
argumentsJSON: #"{"text":"Inspect MCP resources","profile_name":"default","text_profile_id":"mcp-text","request_context":{"source":"mcp","topic":"catalog-runtime","attributes":{"caller.app":"SpeakSwiftlyServerLibraryTests","caller.project":"SpeakSwiftlyServer","surface":"mcp"}},"cwd":"./Tests","repo_root":".","qwen_pre_model_text_chunking":true}"#,
argumentsJSON: #"{"text":"Inspect MCP resources","profile_name":"default","text_profile_id":"mcp-text","request_context":{"reqPurpose":"speech","source":"mcp","topic":"catalog-runtime","attributes":{"caller.app":"SpeakSwiftlyServerLibraryTests","caller.project":"SpeakSwiftlyServer","surface":"mcp"}},"cwd":"./Tests","repo_root":".","qwen_pre_model_text_chunking":true}"#,
),
sessionID: sessionID,
),
Expand All @@ -31,6 +31,7 @@ extension ServerTests {
#expect(
queuedSpeechInvocation.requestContext
== SpeakSwiftly.RequestContext(
reqPurpose: .speech,
source: "mcp",
topic: "catalog-runtime",
cwd: "./Tests",
Expand Down Expand Up @@ -84,6 +85,7 @@ extension ServerTests {
#expect(
retainedAudioArtifact.requestContext
== SpeakSwiftly.RequestContext(
reqPurpose: .audioFile,
source: "mcp",
topic: "generate_audio_file",
attributes: [
Expand Down
35 changes: 35 additions & 0 deletions docs/releases/v8.0.0-release-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# v8.0.0 Release Notes

## What Changed

- Raised the `SpeakSwiftly` dependency floor to `9.0.0`.
- Raised the `TextForSpeech` dependency floor to `0.22.0`.
- Added request-purpose defaults for HTTP and MCP speech routes.
- Defaulted live speech requests to `speech` and retained file or batch requests
to `audioFile`.
- Passed caller-provided `prefacePolicy` through to `TextForSpeech` so clients
can override the source/topic preface policy.
- Updated MCP resources and tool descriptions to document request-purpose
provenance.

## Breaking Changes

- Clients that send a nested `request_context` object must include required
`reqPurpose` when they bypass the route/tool defaults.
- The shared request-context shape now follows `TextForSpeech.RequestContext`
from `0.22.0`.

## Migration Notes

- Use `reqPurpose: "speech"` for live playback.
- Use `reqPurpose: "audioFile"` for retained audio-file and batch generation.
- Use `reqPurpose: "audioStream"` for stream-oriented callers.
- Omit `prefacePolicy` to follow default behavior, set `"always"` to force the
source/topic preface, or set `"never"` to suppress it.
- Do not send `source_format`; generation text is auto-detected and source-file
requests can be inferred from file extensions.

## Verification

- `swift test`
- `sh scripts/repo-maintenance/validate-all.sh`