# Instructions

- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.

# Test info

- Name: gains.spec.ts >> 실제 GainNode의 배경 배율은 8환경 × SNR 경계·0에서 동일하다
- Location: tests/browser/gains.spec.ts:74:1

# Error details

```
Error: expect(received).toBeCloseTo(expected, precision)

Expected: 0.4198884518664126
Received: 1

Expected precision:    6
Expected difference: < 0.0000005
Received difference:   0.5801115481335875
```

# Page snapshot

```yaml
- generic [ref=e2]:
  - link "본문으로 건너뛰기" [ref=e3] [cursor=pointer]:
    - /url: "#main"
  - banner [ref=e4]:
    - generic [ref=e5]:
      - heading "단어를 맞춰라" [level=1] [ref=e11]
      - generic [ref=e12]: v0.2.5
    - generic [ref=e13]:
      - button "문제 풀이 종료" [ref=e14] [cursor=pointer]
      - text: 소리에 집중하는 시간
  - main [ref=e16]:
    - region "단어 찾기 게임" [ref=e17]:
      - generic [ref=e18]:
        - generic [ref=e19]: 나의 소리 탐험
        - generic [ref=e20]: 문제 01 / 3
      - progressbar "현재 문항 진행률" [ref=e21]
      - generic [ref=e22]:
        - complementary [ref=e23]:
          - paragraph [ref=e24]: 지금 듣고 있는 공간
          - heading "길거리" [level=2] [ref=e25]
          - paragraph [ref=e26]: 지나가는 차와 도시의 웅성임
          - generic [ref=e33]: S
          - generic [ref=e70]: 1단계 · SNR -12 dB
          - paragraph [ref=e71]: 소리에 익숙해지기
          - generic [ref=e72]:
            - generic [ref=e73]: 목표 음성 SNR
            - slider "목표 음성 SNR" [ref=e74] [cursor=pointer]: "-12"
            - status [ref=e75]: SNR -12 dB
            - paragraph [ref=e76]: 음성이 기준보다 12 dB 작아요. 범위 −12~+12 dB · 1 dB 간격. 배경 크기는 일정해요. 양수는 음성을 더 크게, 0은 기준 크기로, 음수는 더 작게 들려줘요. 변경은 다음 재생·재청취부터 적용돼요.
          - generic [ref=e77]:
            - generic [ref=e78]: ◖)) 전체 음량
            - slider "전체 음량" [ref=e79] [cursor=pointer]: "100"
            - status [ref=e80]: 100%
          - generic [ref=e81]:
            - generic [ref=e82]:
              - text: 점수
              - generic [ref=e83]: "0"
            - generic [ref=e84]:
              - text: 연속 정답
              - generic [ref=e85]: "0"
        - generic [ref=e86]:
          - generic [ref=e87]:
            - generic [ref=e88]: 두 번의 힌트
            - generic [ref=e89]: 객관식
          - generic [ref=e90]:
            - generic [aria-hidden] [ref=e91]: ♬
            - heading "소리 사이에 집중해 보세요." [level=2] [ref=e92]
            - paragraph [ref=e93]: 문장이 끝나면 질문이 나타나요.작은 단어 하나를 기억해 주세요.
            - status [ref=e94]: 에스의 목소리를 듣고 있어요…
          - generic [ref=e95]:
            - button "듣는 중…" [disabled] [ref=e96]
            - button "듣기 중단" [ref=e97] [cursor=pointer]
            - generic [ref=e98]: 다시 들어도 점수는 줄어들지 않아요.
  - contentinfo [ref=e99]:
    - paragraph [ref=e100]: Designed by OZ at Heat-T Medical Inc.
    - paragraph [ref=e101]: 청각적 주의·식별 게임 · 의료적 진단이나 치료를 제공하지 않습니다.
    - generic [ref=e102]: 개인정보 수집 없이, 오직 플레이만.
    - link "환경음 출처·라이선스" [ref=e103] [cursor=pointer]:
      - /url: /ambience/AMBIENCE_NOTICES.md
```

# Test source

```ts
  19  |       }[];
  20  |     };
  21  |   }
  22  | }
  23  | test.beforeEach(async ({ page }) => {
  24  |   await page.addInitScript(() => {
  25  |     window.mixerProbe = { sources: [] };
  26  |     const edges = new WeakMap<AudioNode, AudioNode>();
  27  |     const settings = new WeakMap<AudioParam, number>();
  28  |     const connect = AudioNode.prototype.connect;
  29  |     const set = AudioParam.prototype.setValueAtTime;
  30  |     const start = AudioBufferSourceNode.prototype.start;
  31  |     AudioNode.prototype.connect = function (
  32  |       this: AudioNode,
  33  |       destination: AudioNode,
  34  |       output?: number,
  35  |       input?: number,
  36  |     ) {
  37  |       const result = Reflect.apply(connect, this, [
  38  |         destination,
  39  |         output ?? 0,
  40  |         input ?? 0,
  41  |       ]);
  42  |       edges.set(this, destination);
  43  |       return result;
  44  |     } as typeof connect;
  45  |     AudioParam.prototype.setValueAtTime = function (value, time) {
  46  |       const result = set.call(this, value, time);
  47  |       if (value > 0) settings.set(this, value);
  48  |       return result;
  49  |     };
  50  |     AudioBufferSourceNode.prototype.start = function (...args) {
  51  |       const gain = edges.get(this) as GainNode;
  52  |       const master = edges.get(gain) as GainNode;
  53  |       const data = this.buffer!.getChannelData(0);
  54  |       const record = {
  55  |         loop: this.loop,
  56  |         duration: this.buffer!.duration,
  57  |         rate: this.buffer!.sampleRate,
  58  |         peak: data.reduce((max, x) => Math.max(max, Math.abs(x)), 0),
  59  |         rms: Math.sqrt(data.reduce((sum, x) => sum + x * x, 0) / data.length),
  60  |         scheduled: settings.get(gain.gain)!,
  61  |         master: master.gain.value,
  62  |         observed: undefined as number | undefined,
  63  |       };
  64  |       window.mixerProbe.sources.push(record);
  65  |       if (this.loop)
  66  |         setTimeout(() => {
  67  |           record.observed = gain.gain.value;
  68  |         }, 350);
  69  |       return start.apply(this, args);
  70  |     };
  71  |   });
  72  | });
  73  | 
  74  | test("실제 GainNode의 배경 배율은 8환경 × SNR 경계·0에서 동일하다", async ({
  75  |   page,
  76  |   browser,
  77  | }, info) => {
  78  |   const errors: string[] = [],
  79  |     failed: string[] = [],
  80  |     external: string[] = [];
  81  |   page.on("pageerror", (e) => errors.push(e.message));
  82  |   page.on("console", (m) => {
  83  |     if (m.type() === "error") errors.push(m.text());
  84  |   });
  85  |   page.on("requestfailed", (r) => failed.push(r.url()));
  86  |   page.on("request", (r) => {
  87  |     if (new URL(r.url()).origin !== "http://127.0.0.1:4173")
  88  |       external.push(r.url());
  89  |   });
  90  |   await page.goto("/");
  91  |   // master=1에서도 같은 안전 상한을 만족하는지 확인한다.
  92  |   await page.getByRole("slider", { name: "전체 음량" }).fill("100");
  93  |   const rows = [];
  94  |   for (const environment of ENVIRONMENTS)
  95  |     for (const db of [-12, 0, 12]) {
  96  |       await page.getByLabel("배경 환경").selectOption(environment.id);
  97  |       await page
  98  |         .getByRole("slider", { name: "목표 음성 SNR" })
  99  |         .fill(String(db));
  100 |       await page.evaluate(() => {
  101 |         window.mixerProbe.sources = [];
  102 |       });
  103 |       await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
  104 |       await expect
  105 |         .poll(() =>
  106 |           page.evaluate(
  107 |             () =>
  108 |               window.mixerProbe.sources.filter((s) => s.observed !== undefined)
  109 |                 .length,
  110 |           ),
  111 |         )
  112 |         .toBe(1);
  113 |       const sources = await page.evaluate(() => window.mixerProbe.sources);
  114 |       expect(sources).toHaveLength(3);
  115 |       const [background, intro, voice] = sources;
  116 |       expect(background!.loop).toBe(true);
  117 |       expect(background!.duration).toBe(12);
  118 |       expect(background!.scheduled).toBe(FIXED_HEADROOM_GAIN);
> 119 |       expect(background!.observed).toBeCloseTo(FIXED_HEADROOM_GAIN, 6);
      |                                    ^ Error: expect(received).toBeCloseTo(expected, precision)
  120 |       expect(voice!.scheduled / background!.scheduled).toBeCloseTo(
  121 |         10 ** (db / 20),
  122 |         10,
  123 |       );
  124 |       expect(intro!.scheduled).toBeCloseTo(FIXED_HEADROOM_GAIN * 1.8, 10);
  125 |       expect(voice!.rms).toBeCloseTo(0.03, 6);
  126 |       expect(intro!.rms).toBeCloseTo(0.03, 6);
  127 |       for (const [kind, source] of [
  128 |         ["background", background],
  129 |         ["voice", voice],
  130 |         ["intro", intro],
  131 |       ] as const) {
  132 |         expect(source!.peak).toBeLessThanOrEqual(PEAK_BOUNDS[kind]);
  133 |         expect(source!.master).toBe(1);
  134 |       }
  135 |       rows.push({ environment: environment.id, db, sources });
  136 |       await page.getByRole("button", { name: "문제 풀이 종료" }).click();
  137 |       await page.getByRole("button", { name: "처음 화면으로" }).click();
  138 |     }
  139 |   // 같은 PCM의 RMS도 모든 SNR에서 보존된다. 배경 재정규화나 gain 보상이 없어야 한다.
  140 |   for (const environment of ENVIRONMENTS) {
  141 |     const rms = rows
  142 |       .filter((r) => r.environment === environment.id)
  143 |       .map((r) => r.sources[0]!.rms);
  144 |     expect(new Set(rms).size).toBe(1);
  145 |   }
  146 |   expect({ errors, failed, external }).toEqual({
  147 |     errors: [],
  148 |     failed: [],
  149 |     external: [],
  150 |   });
  151 |   await info.attach("actual-gain-nodes", {
  152 |     body: JSON.stringify(
  153 |       {
  154 |         chrome: browser.version(),
  155 |         fixedGain: FIXED_HEADROOM_GAIN,
  156 |         rows,
  157 |         errors,
  158 |         failed,
  159 |         external,
  160 |       },
  161 |       null,
  162 |       2,
  163 |     ),
  164 |     contentType: "application/json",
  165 |   });
  166 | });
  167 | 
  168 | // HTTP 200으로 반환되는 실제 WAV 한 sample만 바꾼다. decoder가 허용하는 손상도 hash가 차단해야 한다.
  169 | test("실제 WAV 변조는 소스 시작 전에 거부하고 원본 재시도로 회복한다", async ({
  170 |   page,
  171 | }) => {
  172 |   await page.route(/\/ambience\/.*\.wav/, async (route) => {
  173 |     const response = await route.fetch();
  174 |     const body = await response.body();
  175 |     body[100] = body[100]! ^ 1;
  176 |     await route.fulfill({ response, body });
  177 |   });
  178 |   await page.goto("/");
  179 |   await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
  180 |   await expect(page.getByRole("alert")).toContainText(
  181 |     "소리를 재생하지 못했어요",
  182 |   );
  183 |   expect(await page.evaluate(() => window.mixerProbe.sources)).toEqual([]);
  184 |   await page.unroute(/\/ambience\/.*\.wav/);
  185 |   await page.getByRole("button", { name: "다시 시도" }).click();
  186 |   await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
  187 |     timeout: 30000,
  188 |   });
  189 | });
  190 | 
```