@@ -939,7 +939,12 @@ std::vector<float> sidon_extract_hidden(sidon_context* ctx, const float* pcm_16k
939939 std::vector<float > feats = make_features (ctx->model , pcm_16k, n_samples, T);
940940 if (T <= 0 )
941941 return {};
942- int max_frames = 3000 ; // same O(T^2) attention guard as sidon_restore
942+ // Same O(T^2) hazard as sidon_restore, but a fixed cap here on purpose:
943+ // this is the encoder-only feature path (#431 changed sidon_restore's cap to
944+ // be derived from a memory budget). Callers of extract_hidden are tooling
945+ // and diff harnesses on short clips, not user audio, so the simple bound
946+ // stays until something actually needs the budget form.
947+ int max_frames = 3000 ;
943948 if (const char * e = getenv (" CRISPASR_SIDON_MAX_FRAMES" ); e && e[0 ]) {
944949 const int v = atoi (e);
945950 if (v > 0 )
@@ -1059,99 +1064,68 @@ std::vector<float> sidon_restore(sidon_context* ctx, const float* samples, int n
10591064 if (T <= 0 )
10601065 return {};
10611066
1062- // Guard against O(T^2) attention blowup. The predictor materializes
1063- // (heads, T, T) relative indices and attention scores, so cost grows
1064- // quadratically in the feature-frame count T (~50 frames/sec of input).
1065- // Restoration is utterance-scale; cap T and fail cleanly rather than let a
1066- // multi-minute clip exhaust memory. After the required 1.5 s lookahead,
1067- // the default ~3000-frame cap permits ~58.5 s of user audio; override it
1068- // only when the selected backend has sufficient memory.
1069- int max_frames = 3000 ;
1070- if (const char * e = getenv (" CRISPASR_SIDON_MAX_FRAMES" ); e && e[0 ]) {
1071- const int v = atoi (e);
1072- if (v > 0 )
1073- max_frames = v;
1074- }
1075- // #431: T > max_frames used to be a hard refusal — "split the audio or raise
1076- // CRISPASR_SIDON_MAX_FRAMES" — which rejected any clip over ~58.5 s. The
1077- // reporter's real input was 54 MINUTES, so neither suggestion is a fix:
1078- // raising the cap re-introduces the O(T^2) blowup the cap exists to prevent,
1079- // and asking a user to pre-split with ffmpeg is asking them to implement the
1080- // feature by hand.
1081- //
1082- // The cap is right about the ATTENTION and wrong about the UTTERANCE. This
1083- // function already chunks the DAC decoder below — core window, context on
1084- // both sides, crop to the core — precisely so a long clip decodes in bounded
1085- // memory. The predictor simply never got the same treatment. So the cap now
1086- // bounds ONE PREDICTOR WINDOW instead of the whole file, and anything longer
1087- // is processed as overlapping windows using that existing idiom.
1088- //
1089- // Short inputs are untouched: at T <= max_frames the whole-utterance path
1090- // below runs exactly as before, so nothing that works today changes.
1067+ // #431 — WHY THIS IS A MEMORY BOUND AND NOT A QUALITY ONE.
10911068 //
1092- // WINDOW SIZE IS THE THRESHOLD (2026-09-12, measured — see the A/B below).
1069+ // Measured against the upstream TorchScript reference on Kaggle
1070+ // (tools/kaggle/sidon-length-parity, chr1s4/crispasr-sidon-length-parity v5),
1071+ // comparing our predictor handoff to the reference's at four lengths:
10931072 //
1094- // First attempt sized the window to the MEMORY cap (2400-frame cores) and
1095- // the 62 s output came back degraded, which I wrongly attributed to
1096- // windowing and gated off. A length sweep on the UNCHANGED whole-utterance
1097- // path showed the real effect — quality falls off well before the cap:
1073+ // length whole-utterance vs REF windowed vs REF
1074+ // 11 s 0.994072 (no split: T < window)
1075+ // 30 s 0.997032 0.991427
1076+ // 50 s 0.997106 0.986650
1077+ // 62 s 0.997073 0.973793
10981078 //
1099- // 11 s T= 625 whole-utterance "And so my fellow-americans ask not what
1100- // your country can do for you..." CLEAN
1101- // 30 s T=1575 whole-utterance "...ask not what your country can do
1102- // for you..." CLEAN
1103- // 50 s T=2575 whole-utterance "...it's not what you can DRINK AND do
1104- // for you." DEGRADED
1079+ // Two conclusions, both of which contradict what I believed this morning:
11051080 //
1106- // That 50 s arm is the shipping code with no windowing involved, so the
1107- // cap was never protecting quality — it was hiding the falloff. A/B on that
1108- // same 50 s clip, 900-frame cores vs whole-utterance:
1081+ // 1. THE WHOLE-UTTERANCE PATH DOES NOT DEGRADE WITH LENGTH. It sits at
1082+ // ~0.997 from 30 s to 62 s. An ASR roundtrip had suggested otherwise and
1083+ // was measuring something else — transcript quality under a tiny ASR is
1084+ // not fidelity to the reference, and here the two pointed opposite ways.
11091085 //
1110- // raw input "ask not what your country can do for you ask what you can
1111- // do for your country" (ceiling)
1112- // whole-utt "it's not what you can drink and do for you."
1113- // WINDOWED "is not what your country can do for you, ask what you can
1114- // do for you."
1086+ // 2. WINDOWING THE PREDICTOR IS A DEVIATION, and a worsening one: 0.991 ->
1087+ // 0.987 -> 0.974 as windows multiply. It was removed rather than gated,
1088+ // because a path known to diverge is not a fallback. The attempt is in
1089+ // git history (cf3e6fe2, dbffc3a7, 112c52a1) with its evidence.
1090+ // The reason it cannot work is structural: the DAC chunking a few hundred
1091+ // lines below IS exact, because the decoder is fully convolutional and
1092+ // its cores are sized from dac_receptive_frames(); attention has no
1093+ // receptive field, so no context size makes a windowed core exact.
11151094 //
1116- // So small windows BEAT the status quo on audio that already "works".
1117- // Windowing is therefore the default, and the window is sized for quality
1118- // rather than for how much attention memory happens to fit.
1095+ // So the cap guards MEMORY, and the only honest fix for "a 60 s file should
1096+ // work" is to let it work where the memory exists. The predictor's
1097+ // relative-position attention grows as O(T^2): the docs' measured anchor is
1098+ // 2042 MiB at T=2825, so the budget below is inverted through that.
11191099 //
1120- // The threshold IS the window size: at T <= window there is exactly one
1121- // window and the split is a no-op, so short clips keep the whole-utterance
1122- // path unchanged. Honest about the gap: clean is measured at T=1575 and
1123- // degraded at T=2575, so where inside that band the falloff begins is
1124- // interpolated, not known.
1125- //
1126- // CRISPASR_SIDON_WINDOW_FRAMES=0 restores the old whole-utterance-or-refuse
1127- // behaviour for A/B; the window is also clamped to the memory cap so
1128- // raising one never silently violates the other.
1129- // DEFAULT 0 = OFF, pending the parity result. The A/B that motivated
1130- // windowing measured ASR transcripts, which says "sounds better to
1131- // moonshine-tiny" and NOT "faithful to upstream" — and if the reference
1132- // model degrades with length too, then the whole-utterance path is correct
1133- // and windowing is a deviation however it sounds. Until
1134- // tools/kaggle/sidon-length-parity answers that, the shipped behaviour is
1135- // the old clean refusal and windowing is opt-in.
1136- //
1137- // Set CRISPASR_SIDON_WINDOW_FRAMES=1500 to enable (that is the size whose
1138- // A/B beat whole-utterance at 50 s); 0 keeps it off.
1139- int pred_window_frames = 0 ;
1140- if (const char * e = getenv (" CRISPASR_SIDON_WINDOW_FRAMES" ); e && e[0 ]) {
1100+ // For very long audio this is not squeamishness — at the reporter's actual
1101+ // 54-minute file, T = 162000 and the relative index ALONE is ~1.5 TiB.
1102+ // Splitting is inherent there, and the message says so instead of implying
1103+ // we simply have not tried hard enough.
1104+ int budget_mb = 4096 ;
1105+ if (const char * e = getenv (" CRISPASR_SIDON_MEM_BUDGET_MB" ); e && e[0 ]) {
11411106 const int v = atoi (e);
1142- if (v >= 0 )
1143- pred_window_frames = v;
1107+ if (v > 0 )
1108+ budget_mb = v;
11441109 }
1145- if (pred_window_frames > max_frames)
1146- pred_window_frames = max_frames;
1147-
1148- bool predictor_chunked = (pred_window_frames > 0 && T > pred_window_frames);
1149- if (!predictor_chunked && T > max_frames) {
1110+ // T_max = 2825 * sqrt(budget / 2042), the docs' measured point inverted.
1111+ int max_frames = (int )(2825.0 * std::sqrt ((double )budget_mb / 2042.0 ));
1112+ if (max_frames < 256 )
1113+ max_frames = 256 ;
1114+ if (const char * e = getenv (" CRISPASR_SIDON_MAX_FRAMES" ); e && e[0 ]) {
1115+ const int v = atoi (e);
1116+ if (v > 0 )
1117+ max_frames = v; // explicit override wins over the budget
1118+ }
1119+ if (T > max_frames) {
1120+ const double est_mb = 2042.0 * ((double )T / 2825.0 ) * ((double )T / 2825.0 );
11501121 fprintf (stderr,
1151- " sidon: input too long — %d feature frames (~%.1f s) exceeds the %d-frame cap and "
1152- " windowing is disabled (CRISPASR_SIDON_WINDOW_FRAMES=0).\n "
1153- " Re-enable windowing, split the audio, or raise CRISPASR_SIDON_MAX_FRAMES.\n " ,
1154- T, (double )T / 50.0 , max_frames);
1122+ " sidon: input is %d feature frames (~%.1f s); the predictor's O(T^2) attention would need "
1123+ " roughly %.0f MiB, over the %d MiB budget (cap %d frames, ~%.1f s).\n "
1124+ " If this machine has the memory: CRISPASR_SIDON_MEM_BUDGET_MB=%.0f (or set "
1125+ " CRISPASR_SIDON_MAX_FRAMES=%d directly).\n "
1126+ " Otherwise split the audio — attention cost grows with the SQUARE of duration, so very long "
1127+ " recordings cannot be restored in one pass at any budget.\n " ,
1128+ T, (double )T / 50.0 , est_mb, budget_mb, max_frames, (double )max_frames / 50.0 , est_mb * 1.1 , T);
11551129 return {};
11561130 }
11571131
@@ -1161,102 +1135,28 @@ std::vector<float> sidon_restore(sidon_context* ctx, const float* samples, int n
11611135 std::vector<float > predictor_features;
11621136 std::chrono::steady_clock::time_point graph_done, predictor_start, predictor_done;
11631137
1164- if (!predictor_chunked) {
1165- // ── Whole-utterance path. Unchanged, and reached for every input that
1166- // worked before this change, so short clips stay bit-identical.
1167- if (!prepare_predictor_graph (ctx, T)) {
1168- release_predictor_workspace (ctx);
1169- release_decoder_workspace (ctx);
1170- return {};
1171- }
1172- graph_done = clock::now ();
1173-
1174- set_predictor_inputs (ctx, feats, T);
1175- predictor_start = clock::now ();
1176- core_quant_bcast::audit (ctx->predictor_graph , " sidon" );
1177- if (ggml_backend_sched_graph_compute (ctx->predictor_sched , ctx->predictor_graph ) != GGML_STATUS_SUCCESS ) {
1178- std::fprintf (stderr, " sidon: predictor graph compute failed\n " );
1179- release_predictor_workspace (ctx);
1180- release_decoder_workspace (ctx);
1181- return {};
1182- }
1183- ggml_backend_sched_synchronize (ctx->predictor_sched );
1184- predictor_done = clock::now ();
1185-
1186- predictor_features.resize ((size_t )ggml_nelements (ctx->predictor_output ));
1187- ggml_backend_tensor_get (ctx->predictor_output , predictor_features.data (), 0 ,
1188- predictor_features.size () * sizeof (float ));
1189- } else {
1190- // ── Windowed path (#431). Same core/context/crop shape the DAC decoder
1191- // uses below: run a window, keep only the frames its CORE owns, slide.
1192- //
1193- // Context frames exist because attention at a core edge would
1194- // otherwise see a truncated sequence. They are cropped away, so they
1195- // cost compute and never reach the output. This is NOT bit-identical
1196- // to a whole-utterance run — attention over a window is a different
1197- // computation — but the alternative for these inputs was no output at
1198- // all, and every frame is still produced with a full context span on
1199- // both sides except at the true file boundaries.
1200- //
1201- // The window is sized to the SAME cap the whole-utterance path
1202- // obeys, so peak attention memory is unchanged: whatever T the user's
1203- // machine could handle before, it still handles, just repeatedly.
1204- int pred_context_frames = 300 ;
1205- if (const char * e = getenv (" CRISPASR_SIDON_PREDICTOR_CONTEXT_FRAMES" ); e && e[0 ]) {
1206- const int v = atoi (e);
1207- if (v >= 0 )
1208- pred_context_frames = v;
1209- }
1210- if (pred_context_frames * 2 >= pred_window_frames)
1211- pred_context_frames = std::max (0 , (pred_window_frames / 4 ));
1212- const int pred_core_frames = std::max (1 , pred_window_frames - 2 * pred_context_frames);
1138+ if (!prepare_predictor_graph (ctx, T)) {
1139+ release_predictor_workspace (ctx);
1140+ release_decoder_workspace (ctx);
1141+ return {};
1142+ }
1143+ graph_done = clock::now ();
12131144
1214- std::fprintf (stderr,
1215- " sidon: %d feature frames (~%.1f s) — processing as %d-frame windows with %d "
1216- " frames of context each side (window %d, memory cap %d)\n " ,
1217- T, (double )T / 50.0 , pred_core_frames, pred_context_frames, pred_window_frames, max_frames);
1218-
1219- predictor_features.assign ((size_t )T * pred_hidden, 0 .0f );
1220- graph_done = clock::now ();
1221- predictor_start = clock::now ();
1222-
1223- for (int core_start = 0 ; core_start < T; core_start += pred_core_frames) {
1224- const int core_end = std::min (T, core_start + pred_core_frames);
1225- int win_start = std::max (0 , core_start - pred_context_frames);
1226- int win_end = std::min (T, core_end + pred_context_frames);
1227- const int win_frames = win_end - win_start;
1228-
1229- if (!prepare_predictor_graph (ctx, win_frames)) {
1230- release_predictor_workspace (ctx);
1231- release_decoder_workspace (ctx);
1232- return {};
1233- }
1234- // feats is [T, feat_dim] frame-major; hand the window its own slice.
1235- set_predictor_inputs (ctx, feats, win_frames, win_start);
1236- core_quant_bcast::audit (ctx->predictor_graph , " sidon" );
1237- if (ggml_backend_sched_graph_compute (ctx->predictor_sched , ctx->predictor_graph ) != GGML_STATUS_SUCCESS ) {
1238- std::fprintf (stderr, " sidon: predictor graph compute failed on window [%d,%d)\n " , win_start, win_end);
1239- release_predictor_workspace (ctx);
1240- release_decoder_workspace (ctx);
1241- return {};
1242- }
1243- ggml_backend_sched_synchronize (ctx->predictor_sched );
1244-
1245- std::vector<float > win_out ((size_t )ggml_nelements (ctx->predictor_output ));
1246- ggml_backend_tensor_get (ctx->predictor_output , win_out.data (), 0 , win_out.size () * sizeof (float ));
1247- if (win_out.size () < (size_t )win_frames * pred_hidden) {
1248- std::fprintf (stderr, " sidon: predictor window produced %zu floats, expected %d\n " , win_out.size (),
1249- win_frames * pred_hidden);
1250- release_predictor_workspace (ctx);
1251- release_decoder_workspace (ctx);
1252- return {};
1253- }
1254- std::copy (win_out.begin () + (size_t )(core_start - win_start) * pred_hidden,
1255- win_out.begin () + (size_t )(core_end - win_start) * pred_hidden,
1256- predictor_features.begin () + (size_t )core_start * pred_hidden);
1257- }
1258- predictor_done = clock::now ();
1145+ set_predictor_inputs (ctx, feats, T);
1146+ predictor_start = clock::now ();
1147+ core_quant_bcast::audit (ctx->predictor_graph , " sidon" );
1148+ if (ggml_backend_sched_graph_compute (ctx->predictor_sched , ctx->predictor_graph ) != GGML_STATUS_SUCCESS ) {
1149+ std::fprintf (stderr, " sidon: predictor graph compute failed\n " );
1150+ release_predictor_workspace (ctx);
1151+ release_decoder_workspace (ctx);
1152+ return {};
12591153 }
1154+ ggml_backend_sched_synchronize (ctx->predictor_sched );
1155+ predictor_done = clock::now ();
1156+
1157+ predictor_features.resize ((size_t )ggml_nelements (ctx->predictor_output ));
1158+ ggml_backend_tensor_get (ctx->predictor_output , predictor_features.data (), 0 ,
1159+ predictor_features.size () * sizeof (float ));
12601160
12611161 // Judge the predictor BEFORE the DAC consumes it, so a bad handoff is
12621162 // attributed to the predictor rather than to the decoder it poisons.
0 commit comments