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
7 changes: 6 additions & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,13 @@ services:
restart: unless-stopped

kokoro:
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.1 # PIN β€” do not use latest
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.6.0 # PIN β€” do not use latest
networks: [core] # internal only; control port 8880 NEVER published
# kokoro-fastapi leaks RSS under load (upstream #453 β€” root cause fixed after v0.2.x; #262
# still open). Fresh baseline is ~1.2GiB; this cap is the fail-closed backstop: leak past it
# and the kernel OOM-kills the container, `restart: unless-stopped` brings it back clean in
# seconds. Blast radius is one failed TTS render β€” the broadcast keeps playing music.
mem_limit: 3g
healthcheck:
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/127.0.0.1/8880'"]
interval: 5s
Expand Down
2 changes: 1 addition & 1 deletion src/GenWave.Tts/KokoroVoiceLister.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ public async Task<IReadOnlyList<string>> ListVoicesAsync(CancellationToken ct)
response.EnsureSuccessStatusCode(); // throws HttpRequestException on non-2xx

var payload = await response.Content.ReadFromJsonAsync<KokoroVoicesResponse>(ct);
return payload?.Voices ?? [];
return payload?.VoiceIds() ?? [];
}
}
38 changes: 34 additions & 4 deletions src/GenWave.Tts/KokoroVoicesResponse.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,40 @@
namespace GenWave.Tts;

using System.Text.Json;
using System.Text.Json.Serialization;

/// <summary>
/// Wire shape of Kokoro's <c>GET /v1/audio/voices</c> response: <c>{ "voices": [...] }</c>
/// (confirmed against <c>ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.1</c>, R2). Internal β€” callers only
/// ever see the flattened id list via <see cref="GenWave.Core.Abstractions.ITtsVoiceLister"/>.
/// Wire shape of Kokoro's <c>GET /v1/audio/voices</c> response: <c>{ "voices": [...] }</c>, where
/// each entry is a bare id string (kokoro-fastapi ≀ v0.2.x) or an <c>{ "id": ..., "name": ... }</c>
/// object (v0.6.0, confirmed against <c>ghcr.io/remsky/kokoro-fastapi-cpu:v0.6.0</c>). Entries are
/// held as raw <see cref="JsonElement"/>s so one record deserializes both generations β€”
/// <c>Tts:Endpoint</c> is operator-repointable at runtime (F36.4), so the wire shape on the other
/// end is not fixed at build time. Internal β€” callers only ever see the flattened id list via
/// <see cref="GenWave.Core.Abstractions.ITtsVoiceLister"/>.
/// </summary>
sealed record KokoroVoicesResponse([property: JsonPropertyName("voices")] List<string>? Voices);
sealed record KokoroVoicesResponse([property: JsonPropertyName("voices")] List<JsonElement>? Voices)
{
public IReadOnlyList<string> VoiceIds()
{
if (Voices is null)
return [];

var ids = new List<string>(Voices.Count);
foreach (var entry in Voices)
{
var id = entry.ValueKind switch
{
JsonValueKind.String => entry.GetString(),
JsonValueKind.Object when entry.TryGetProperty("id", out var idProperty)
&& idProperty.ValueKind == JsonValueKind.String
=> idProperty.GetString(),
_ => null,
};

if (!string.IsNullOrEmpty(id))
ids.Add(id);
}

return ids;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,13 @@ public sealed class ScenarioEngineAndComposeCarryZeroDiffFromMain
// STORY-179): the api service gained Icecast__StatsUrl/Icecast__AdminPassword env vars for
// the spectator listener-count poll β€” another intentional edit from a LATER epic, still not
// a regression of Epic V's own F41–F45 zero-diff promise. EngineScriptSha256 unchanged.
// ComposeYamlSha256 re-pinned 2026-07-19 (kokoro image bump): the kokoro service moved to
// kokoro-fastapi-cpu v0.6.0 and gained a mem_limit backstop for the upstream RSS leak
// (remsky/Kokoro-FastAPI#453) β€” an intentional ops edit from outside this epic, not a
// regression of its zero-diff promise. EngineScriptSha256 is untouched.
//
const string EngineScriptSha256 = "a256fd3f2797ed9b52e3f8507e8ca610aa02218e2fedc5c231369f0ccaab9bd6";
const string ComposeYamlSha256 = "23490e050a79e87b6763e64e3b89ae507a1fa682b40f7e9029601221188f5446";
const string ComposeYamlSha256 = "bcff1c88105845cef82314dd774336095b1df38ec07e084038547bc374ea1b25";

[Fact]
public void EngineScriptByteMatchesMain()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,13 @@ public sealed class ScenarioEngineAndComposeCarryZeroDiffFromMain
// STORY-179): the api service gained Icecast__StatsUrl/Icecast__AdminPassword env vars for
// the spectator listener-count poll β€” another intentional edit from a LATER epic, not a
// regression of F46–F51 touching neither file. EngineScriptSha256 unchanged.
// ComposeYamlSha256 re-pinned 2026-07-19 (kokoro image bump): the kokoro service moved to
// kokoro-fastapi-cpu v0.6.0 and gained a mem_limit backstop for the upstream RSS leak
// (remsky/Kokoro-FastAPI#453) β€” an intentional ops edit from outside this epic, not a
// regression of its zero-diff promise. EngineScriptSha256 is untouched.
//
const string EngineScriptSha256 = "a256fd3f2797ed9b52e3f8507e8ca610aa02218e2fedc5c231369f0ccaab9bd6";
const string ComposeYamlSha256 = "23490e050a79e87b6763e64e3b89ae507a1fa682b40f7e9029601221188f5446";
const string ComposeYamlSha256 = "bcff1c88105845cef82314dd774336095b1df38ec07e084038547bc374ea1b25";

[Fact]
public void EngineScriptByteMatchesMain()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,13 @@ public sealed class ScenarioEngineAndComposeCarryZeroDiffFromMain
// STORY-179): the api service gained Icecast__StatsUrl/Icecast__AdminPassword env vars for
// the spectator listener-count poll β€” another intentional edit from a LATER epic, not a
// regression of F52–F56 touching neither file. EngineScriptSha256 unchanged.
// ComposeYamlSha256 re-pinned 2026-07-19 (kokoro image bump): the kokoro service moved to
// kokoro-fastapi-cpu v0.6.0 and gained a mem_limit backstop for the upstream RSS leak
// (remsky/Kokoro-FastAPI#453) β€” an intentional ops edit from outside this epic, not a
// regression of its zero-diff promise. EngineScriptSha256 is untouched.
//
const string EngineScriptSha256 = "a256fd3f2797ed9b52e3f8507e8ca610aa02218e2fedc5c231369f0ccaab9bd6";
const string ComposeYamlSha256 = "23490e050a79e87b6763e64e3b89ae507a1fa682b40f7e9029601221188f5446";
const string ComposeYamlSha256 = "bcff1c88105845cef82314dd774336095b1df38ec07e084038547bc374ea1b25";

[Fact]
public void EngineScriptByteMatchesMain()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,13 @@ public sealed class ScenarioEngineAndComposeCarryZeroDiffFromMain
// STORY-179): a third intentional edit that DOES touch compose.yaml β€” the api service
// gained Icecast__StatsUrl/Icecast__AdminPassword env vars for the spectator listener-count
// poll. EngineScriptSha256 unchanged β€” T21 does not touch engine/genwave.liq.
// ComposeYamlSha256 re-pinned 2026-07-19 (kokoro image bump): the kokoro service moved to
// kokoro-fastapi-cpu v0.6.0 and gained a mem_limit backstop for the upstream RSS leak
// (remsky/Kokoro-FastAPI#453) β€” an intentional ops edit from outside this epic, not a
// regression of its zero-diff promise. EngineScriptSha256 is untouched.
//
const string EngineScriptSha256 = "a256fd3f2797ed9b52e3f8507e8ca610aa02218e2fedc5c231369f0ccaab9bd6";
const string ComposeYamlSha256 = "23490e050a79e87b6763e64e3b89ae507a1fa682b40f7e9029601221188f5446";
const string ComposeYamlSha256 = "bcff1c88105845cef82314dd774336095b1df38ec07e084038547bc374ea1b25";

[Fact]
public void EngineScriptByteMatchesMain()
Expand Down
2 changes: 1 addition & 1 deletion tests/GenWave.Host.Tests/kokoro-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ name: genwave-kokorotest

services:
kokoro:
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.1
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.6.0
ports:
- "127.0.0.1:18880:8880"
healthcheck:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// STORY-097 β€” KokoroVoicesResponse deserializes both generations of the voices wire shape
//
// BDD specification β€” xUnit. kokoro-fastapi ≀ v0.2.x served GET /v1/audio/voices as
// { "voices": ["af_heart", ...] }; v0.6.0 serves { "voices": [{ "id": "af_heart",
// "name": "af_heart" }, ...] } (captured from the real ghcr.io/remsky/kokoro-fastapi-cpu:v0.6.0
// container during the image bump). Tts:Endpoint is operator-repointable at runtime (F36.4), so
// the client must flatten EITHER shape to the same id list rather than assume the pinned image's.

namespace GenWave.Tts.Tests.Specs;

using System.Text.Json;

public static class FeatureKokoroVoicesWireShapes
{
static KokoroVoicesResponse Parse(string json)
{
var parsed = JsonSerializer.Deserialize<KokoroVoicesResponse>(json);
Assert.NotNull(parsed);
return parsed;
}

public sealed class ScenarioLegacyStringArrayShape
{
// The ≀ v0.2.x shape β€” the one KokoroStubServer and every shipped repoint spec emulate.
readonly IReadOnlyList<string> ids =
Parse("""{"voices":["af_heart","am_adam","zm_yunyang"]}""").VoiceIds();

[Fact]
public void YieldsEveryIdInOrder() =>
Assert.Equal(["af_heart", "am_adam", "zm_yunyang"], ids);
}

public sealed class ScenarioObjectShape
{
// The v0.6.0 shape, verbatim from the real container's response.
readonly IReadOnlyList<string> ids =
Parse("""{"voices":[{"id":"af_alloy","name":"af_alloy"},{"id":"af_heart","name":"af_heart"}]}""")
.VoiceIds();

[Fact]
public void YieldsTheIdOfEveryObjectInOrder() =>
Assert.Equal(["af_alloy", "af_heart"], ids);
}

public sealed class ScenarioDegenerateEntries
{
[Fact]
public void ANullVoicesPropertyYieldsAnEmptyList() =>
Assert.Empty(Parse("""{"voices":null}""").VoiceIds());

[Fact]
public void AMissingVoicesPropertyYieldsAnEmptyList() =>
Assert.Empty(Parse("""{}""").VoiceIds());

[Fact]
public void AnEntryOfAnUnrecognizedKindIsSkippedNotThrown() =>
Assert.Equal(["af_heart"], Parse("""{"voices":[42,{"name":"no-id"},"af_heart"]}""").VoiceIds());

[Fact]
public void AnEmptyStringEntryIsSkipped() =>
Assert.Empty(Parse("""{"voices":[""]}""").VoiceIds());
}
}
Loading